feat(web): add dynamic sub-status to the generating step

Keep 3 high-level steps but give the long "generating" phase concrete,
moving feedback (option A) instead of splitting into more, less-reliable
steps:

- Derive a sub-status from the agent's TodoWrite plan: the in-progress
  task label (activeForm) plus a done/total count, falling back to the
  latest write/edit target file when no plan was emitted.
- The count counts the in-progress task toward `done` to match the
  chat-side todo card (e.g. 3/7 on both sides).
- Suppress the higher-level narration line while the sub-status is shown
  so only one dynamic line appears at a time (early phase = narration,
  writing phase = concrete task + count).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
chaoxiaoche 2026-05-29 17:21:07 +08:00
parent 716000e200
commit 4ca4df0cab
4 changed files with 211 additions and 5 deletions

View file

@ -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;
}
}

View file

@ -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 (
<section
className={styles.stage}
@ -64,9 +69,11 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
<Icon name={markIcon} size={24} />
</div>
<h1 className={styles.title}>{title}</h1>
<p className={styles.lead} data-live={generating && Boolean(model.activityLabel)}>
{lead}
</p>
{showSubstatus ? null : (
<p className={styles.lead} data-live={generating && Boolean(model.activityLabel)}>
{lead}
</p>
)}
<div
className={styles.progress}
data-active={generating}
@ -96,6 +103,21 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
</li>
))}
</ol>
{generating && (model.detailLabel || model.todoProgress) ? (
<div
key={`${model.detailLabel ?? ''}-${model.todoProgress?.done ?? ''}`}
className={styles.substatus}
>
{model.detailLabel ? (
<span className={styles.substatusLabel}>{model.detailLabel}</span>
) : null}
{model.todoProgress ? (
<span className={styles.substatusCount}>
{model.todoProgress.done}/{model.todoProgress.total}
</span>
) : null}
</div>
) : null}
{generating ? (
<div className={styles.meta}>
<span data-testid="generation-preview-elapsed">

View file

@ -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<string, unknown>;
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(

View file

@ -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',