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 (

{title}

-

- {lead} -

+ {showSubstatus ? null : ( +

+ {lead} +

+ )}
))} + {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',