mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
716000e200
commit
4ca4df0cab
4 changed files with 211 additions and 5 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue