feat(web): reveal generation steps progressively as the agent reaches them

- Only render steps the agent has actually reached (drop pending pills)
  with a slide/fade entrance, so the card visibly evolves 1->2->3 instead
  of always showing the same fully-populated row.
- Keep the "understand" step in progress during requesting/starting so a
  fresh run opens with a single step rather than a pre-filled set.
- Stop surfacing status detail (e.g. the model slug from `requesting`) as
  the live activity line; only genuine thinking/output text is shown.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
chaoxiaoche 2026-05-29 16:01:35 +08:00
parent 0eaf1c37d9
commit 716000e200
4 changed files with 46 additions and 6 deletions

View file

@ -153,6 +153,20 @@
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12px;
/* Steps are revealed one at a time as the agent reaches them, so each
pill slides + fades in on mount to make the progression visible. */
animation: stepReveal 280ms cubic-bezier(0.23, 1, 0.32, 1);
}
@keyframes stepReveal {
from {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.step[data-status='running'] {
@ -207,7 +221,8 @@
.mark[data-active='true'],
.progress[data-active='true']::after,
.stepDot[data-running='true'],
.lead[data-live='true'] {
.lead[data-live='true'],
.step {
animation: none;
}
}

View file

@ -79,7 +79,9 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
<span style={{ width: `${model.progressPercent}%` }} />
</div>
<ol className={styles.steps}>
{model.steps.map((step) => (
{model.steps
.filter((step) => step.status !== 'pending')
.map((step) => (
<li key={step.id} className={styles.step} data-status={step.status}>
<span className={styles.stepIcon} aria-hidden>
{step.status === 'succeeded' ? (

View file

@ -172,7 +172,12 @@ export function derivePrototypeGenerationSteps(input: {
let understand: GenerationStepStatus = 'running';
if (input.failed && !hasText && !hasToolUse) {
understand = 'failed';
} else if (hasText || hasStatus(['thinking', 'streaming', 'requesting', 'starting']) || hasToolUse) {
} else if (hasText || hasStatus(['thinking', 'streaming']) || hasToolUse) {
// `requesting`/`starting` only mean the request left the client — the
// model hasn't produced anything yet, so we keep "understand" in
// progress until real thinking/output/tool activity arrives. This lets
// the UI reveal the steps one at a time instead of jumping straight to
// a fully populated row.
understand = 'succeeded';
}
@ -241,9 +246,9 @@ function latestActivityLabel(events: AgentEvent[]): string | null {
if (event.kind === 'text' && event.text.trim() && !QUESTION_FORM_RE.test(event.text)) {
return truncateActivity(event.text);
}
if (event.kind === 'status' && event.detail?.trim()) {
return truncateActivity(event.detail);
}
// Intentionally skip `status` details: their payload is often an
// internal identifier (e.g. the model slug from a `requesting` event)
// rather than human-readable progress, so surfacing it reads as noise.
}
return null;
}

View file

@ -46,6 +46,24 @@ describe('generation preview helpers', () => {
]);
});
it('keeps the understand step in progress while the request is still pending', () => {
// `requesting` only means the request left the client; nothing should
// advance past the first step until real model activity arrives, so the
// UI can reveal steps one at a time.
expect(
derivePrototypeGenerationSteps({
events: [{ kind: 'status', label: 'requesting', detail: 'claude-opus-4-7' }],
hasArtifactHtml: false,
hasPreviewSurface: false,
failed: false,
}),
).toEqual([
{ id: 'understand', status: 'running' },
{ id: 'generate', status: 'pending' },
{ id: 'prepare', status: 'pending' },
]);
});
it('builds preview state for an active assistant run without an open preview tab', () => {
const assistant: ChatMessage = {
id: 'a1',