diff --git a/apps/web/src/components/GenerationPreviewStage.module.css b/apps/web/src/components/GenerationPreviewStage.module.css
index 33213a3f1..a0d55c212 100644
--- a/apps/web/src/components/GenerationPreviewStage.module.css
+++ b/apps/web/src/components/GenerationPreviewStage.module.css
@@ -142,6 +142,35 @@
list-style: none;
}
+/* Dynamic sub-status for the long "generating" step: the concrete task
+ plus a count, refreshed as the agent advances so the middle phase keeps
+ moving without splitting into more (less reliable) discrete steps. */
+.substatus {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ max-width: 420px;
+ font-size: 12px;
+ color: var(--text-faint);
+ animation: leadFade 240ms ease-out;
+}
+
+.substatusLabel {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.substatusCount {
+ flex-shrink: 0;
+ padding: 1px 7px;
+ border-radius: var(--radius-pill);
+ border: 1px solid var(--border);
+ background: var(--bg-panel);
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
.step {
display: inline-flex;
align-items: center;
@@ -222,7 +251,8 @@
.progress[data-active='true']::after,
.stepDot[data-running='true'],
.lead[data-live='true'],
- .step {
+ .step,
+ .substatus {
animation: none;
}
}
diff --git a/apps/web/src/components/GenerationPreviewStage.tsx b/apps/web/src/components/GenerationPreviewStage.tsx
index 8649917c6..4a67d8125 100644
--- a/apps/web/src/components/GenerationPreviewStage.tsx
+++ b/apps/web/src/components/GenerationPreviewStage.tsx
@@ -52,6 +52,11 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
const markIcon =
model.phase === 'failed' ? 'close' : model.phase === 'stopped' ? 'stop' : 'sparkles';
+ // Once concrete sub-status (current task + count) is available we let it
+ // carry the live signal and drop the higher-level narration line, so only
+ // one dynamic line shows at a time.
+ const showSubstatus = generating && Boolean(model.detailLabel || model.todoProgress);
+
return (
))}
+ {generating && (model.detailLabel || model.todoProgress) ? (
+
+ {model.detailLabel ? (
+ {model.detailLabel}
+ ) : null}
+ {model.todoProgress ? (
+
+ {model.todoProgress.done}/{model.todoProgress.total}
+
+ ) : null}
+
+ ) : null}
{generating ? (
diff --git a/apps/web/src/runtime/generation-preview.ts b/apps/web/src/runtime/generation-preview.ts
index 21b3fad03..9f6db8253 100644
--- a/apps/web/src/runtime/generation-preview.ts
+++ b/apps/web/src/runtime/generation-preview.ts
@@ -1,5 +1,6 @@
import type { AgentEvent, ChatMessage, LiveArtifactSummary, ProjectFile } from '../types';
import { isLiveArtifactTabId, liveArtifactTabId } from '../types';
+import { isTodoWriteToolName, latestTodosFromEvents, type TodoItem } from './todos';
export type GenerationStepStatus = 'pending' | 'running' | 'succeeded' | 'failed';
@@ -23,12 +24,29 @@ export interface GenerationPreviewModel {
* shows real movement instead of a frozen card.
*/
activityLabel: string | null;
+ /**
+ * Concrete sub-status for the long "generating" phase, e.g. the
+ * in-progress task ("Writing index.html") or the current write target.
+ * Lets the middle step show movement without splitting into more
+ * (less reliable) discrete steps. Only set while generating.
+ */
+ detailLabel: string | null;
+ /**
+ * Task counter derived from the agent's TodoWrite plan, e.g. 3/8. The
+ * in-progress task counts toward `done` to match the chat-side todo card.
+ * Only set while generating and when the agent emitted a plan.
+ */
+ todoProgress: { done: number; total: number } | null;
}
// Matches the inline forms the agent emits to ask the user clarifying
// questions before continuing (see artifacts/question-form.ts).
const QUESTION_FORM_RE = /<(question-form|ask-question)\b/i;
+// Tools that represent concrete generation work (writing/editing files,
+// running commands) as opposed to reads/plans.
+const WRITE_LIKE_TOOL_RE = /^(write|edit|multiedit|bash|run_terminal_cmd)$/i;
+
const PREVIEWABLE_FILE = /\.(html?|jsx|tsx|svg|md|pdf|pptx?|key)$/i;
export function workspaceHasPreviewSurface(input: {
@@ -137,6 +155,18 @@ export function buildGenerationPreviewState(input: {
const startedAt = latestAssistant.startedAt ?? latestAssistant.createdAt ?? Date.now();
+ const generating = phase === 'generating';
+ const todos = generating ? latestTodosFromEvents(events) : [];
+ const todoProgress =
+ todos.length > 0
+ ? {
+ done: todos.filter(
+ (todo) => todo.status === 'completed' || todo.status === 'in_progress',
+ ).length,
+ total: todos.length,
+ }
+ : null;
+
return {
startedAt,
steps: derived.steps,
@@ -144,7 +174,9 @@ export function buildGenerationPreviewState(input: {
failed,
errorMessage: derived.errorMessage,
progressPercent: derived.progressPercent,
- activityLabel: phase === 'generating' ? latestActivityLabel(events) : null,
+ activityLabel: generating ? latestActivityLabel(events) : null,
+ detailLabel: generating ? generationDetailLabel(events, todos) : null,
+ todoProgress,
retryTarget: failed ? latestAssistant : null,
};
}
@@ -258,6 +290,40 @@ function truncateActivity(text: string): string {
return collapsed.length > 80 ? `${collapsed.slice(0, 79)}…` : collapsed;
}
+// The concrete operation behind the "generating" step. Prefers the agent's
+// own in-progress task label (TodoWrite `activeForm`/content), then falls
+// back to the most recent write/edit target file so the middle phase still
+// shows movement when no plan was emitted.
+function generationDetailLabel(events: AgentEvent[], todos: TodoItem[]): string | null {
+ const active = todos.find((todo) => todo.status === 'in_progress');
+ if (active) {
+ const label = active.activeForm?.trim() || active.content.trim();
+ if (label) return truncateActivity(label);
+ }
+ for (let index = events.length - 1; index >= 0; index -= 1) {
+ const event = events[index]!;
+ if (
+ event.kind === 'tool_use'
+ && typeof event.name === 'string'
+ && !isTodoWriteToolName(event.name)
+ && WRITE_LIKE_TOOL_RE.test(event.name)
+ ) {
+ const target = toolTargetName(event.input);
+ if (target) return target;
+ }
+ }
+ return null;
+}
+
+function toolTargetName(input: unknown): string | null {
+ if (!input || typeof input !== 'object') return null;
+ const obj = input as Record;
+ const raw = obj.file_path ?? obj.filePath ?? obj.path ?? obj.file;
+ if (typeof raw !== 'string' || !raw.trim()) return null;
+ const segments = raw.trim().split(/[\\/]/);
+ return segments[segments.length - 1] || raw.trim();
+}
+
function eventsHaveStatus(events: AgentEvent[], labels: string[]): boolean {
const normalized = new Set(labels.map((label) => label.toLowerCase()));
return events.some(
diff --git a/apps/web/tests/runtime/generation-preview.test.ts b/apps/web/tests/runtime/generation-preview.test.ts
index a8c047247..277a2366c 100644
--- a/apps/web/tests/runtime/generation-preview.test.ts
+++ b/apps/web/tests/runtime/generation-preview.test.ts
@@ -112,6 +112,94 @@ describe('generation preview helpers', () => {
expect(state?.activityLabel).toBe('Sketching the hero section layout');
});
+ it('derives a concrete sub-status and task count while generating', () => {
+ const assistant: ChatMessage = {
+ id: 'a1',
+ role: 'assistant',
+ content: '',
+ runStatus: 'running',
+ startedAt: Date.now(),
+ events: [
+ {
+ kind: 'tool_use',
+ id: 't1',
+ name: 'TodoWrite',
+ input: {
+ todos: [
+ { content: 'Plan layout', status: 'completed' },
+ { content: 'Write index.html', activeForm: 'Writing index.html', status: 'in_progress' },
+ { content: 'Self-check', status: 'pending' },
+ ],
+ },
+ },
+ ],
+ };
+ const state = buildGenerationPreviewState({
+ designSystemProject: false,
+ messages: [assistant],
+ streaming: true,
+ activeTab: null,
+ projectFiles: [],
+ liveArtifacts: [],
+ });
+ expect(state?.detailLabel).toBe('Writing index.html');
+ // The in-progress task counts toward `done`, matching the chat todo card.
+ expect(state?.todoProgress).toEqual({ done: 2, total: 3 });
+ });
+
+ it('falls back to the latest write target when no plan is present', () => {
+ const assistant: ChatMessage = {
+ id: 'a1',
+ role: 'assistant',
+ content: '',
+ runStatus: 'running',
+ startedAt: Date.now(),
+ events: [
+ { kind: 'text', text: 'Writing the page now.' },
+ { kind: 'tool_use', id: 't1', name: 'Write', input: { file_path: 'src/index.html' } },
+ ],
+ };
+ const state = buildGenerationPreviewState({
+ designSystemProject: false,
+ messages: [assistant],
+ streaming: true,
+ activeTab: null,
+ projectFiles: [],
+ liveArtifacts: [],
+ });
+ expect(state?.detailLabel).toBe('index.html');
+ expect(state?.todoProgress).toBeNull();
+ });
+
+ it('omits sub-status data once the run is no longer generating', () => {
+ const assistant: ChatMessage = {
+ id: 'a1',
+ role: 'assistant',
+ content: 'Partial work',
+ runStatus: 'canceled',
+ startedAt: Date.now(),
+ events: [
+ {
+ kind: 'tool_use',
+ id: 't1',
+ name: 'TodoWrite',
+ input: { todos: [{ content: 'Write index.html', status: 'in_progress' }] },
+ },
+ ],
+ };
+ const state = buildGenerationPreviewState({
+ designSystemProject: false,
+ messages: [assistant],
+ streaming: false,
+ activeTab: null,
+ projectFiles: [],
+ liveArtifacts: [],
+ });
+ expect(state?.phase).toBe('stopped');
+ expect(state?.detailLabel).toBeNull();
+ expect(state?.todoProgress).toBeNull();
+ });
+
it('keeps a paused surface when the run was stopped without a preview', () => {
const assistant: ChatMessage = {
id: 'a1',