mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(web): wire generation preview stage into workspace Show a 3-step progress overlay (understand → generate → prepare) in the preview area while artifacts are being generated, replacing the blank empty state. Displays elapsed time, an estimated duration hint, and a retry button on failure. - Add GenerationPreviewStage component + CSS module + runtime helpers - Integrate buildGenerationPreviewState into FileWorkspace - Pass messages/artifact/error/retry from ProjectView to FileWorkspace - Register i18n keys for en and zh-CN locales Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): keep generation preview alive and persistent across waiting states Address UX feedback on the generation preview surface: - Make the waiting card feel alive instead of frozen: breathing mark, sweeping progress shimmer, pulsing running-step dot, and a live activity snippet pulled from streamed events (respects prefers-reduced-motion). - Add an `awaiting-input` phase so the preview no longer reverts to the empty "design will appear here" placeholder when the agent asks the user a clarifying question (detects inline <question-form>). - Add a `stopped` phase so a canceled/paused run keeps a contextual paused card instead of blanking the surface. - Fix workspaceHasPreviewSurface live-artifact tab match (was reading a non-existent `tabId` field) and correct the unit assertion that contradicted the helper's `thinking` handling. - Populate generationPreview.* keys (incl. new awaiting/stopped strings) across all locales. Co-authored-by: Cursor <cursoragent@cursor.com> * 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> * 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> * feat(web): drop elapsed timer and duplicate estimate from generation preview The "usually 2–5 minutes" estimate showed twice (lead footnote + meta row) and the elapsed counter added little signal, so remove both: delete the meta row, stop falling back to the estimate footnote in the generating lead (render the lead only when live narration exists), and drop the now unused elapsed timer/util. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
311 lines
9.5 KiB
TypeScript
311 lines
9.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
buildGenerationPreviewState,
|
|
derivePrototypeGenerationSteps,
|
|
workspaceHasPreviewSurface,
|
|
} from '../../src/runtime/generation-preview';
|
|
import type { AgentEvent, ChatMessage } from '../../src/types';
|
|
|
|
describe('generation preview helpers', () => {
|
|
it('detects when the workspace already has a preview surface', () => {
|
|
expect(
|
|
workspaceHasPreviewSurface({
|
|
activeTab: 'index.html',
|
|
projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }],
|
|
liveArtifacts: [],
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
workspaceHasPreviewSurface({
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
streamingArtifactHtml: '<html><body>hi</body></html>',
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('advances the three prototype steps from streamed events', () => {
|
|
const events: AgentEvent[] = [
|
|
{ kind: 'status', label: 'thinking' },
|
|
{ kind: 'text', text: 'Planning the page.' },
|
|
{ kind: 'tool_use', id: '1', name: 'Write', input: { file_path: 'index.html' } },
|
|
];
|
|
expect(
|
|
derivePrototypeGenerationSteps({
|
|
events,
|
|
hasArtifactHtml: false,
|
|
hasPreviewSurface: false,
|
|
failed: false,
|
|
}),
|
|
).toEqual([
|
|
{ id: 'understand', status: 'succeeded' },
|
|
{ id: 'generate', status: 'succeeded' },
|
|
{ id: 'prepare', status: 'running' },
|
|
]);
|
|
});
|
|
|
|
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',
|
|
role: 'assistant',
|
|
content: '',
|
|
runStatus: 'running',
|
|
startedAt: Date.now() - 5_000,
|
|
events: [{ kind: 'status', label: 'thinking' }],
|
|
};
|
|
const state = buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [{ id: 'u1', role: 'user', content: 'Build a landing page' }, assistant],
|
|
streaming: true,
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
});
|
|
expect(state).not.toBeNull();
|
|
expect(state?.phase).toBe('generating');
|
|
// A `thinking` status is enough evidence that the model started, so the
|
|
// first step has already advanced past "running".
|
|
expect(state?.steps[0]?.status).toBe('succeeded');
|
|
expect(state?.retryTarget).toBeNull();
|
|
});
|
|
|
|
it('surfaces the latest activity snippet while generating', () => {
|
|
const assistant: ChatMessage = {
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: '',
|
|
runStatus: 'running',
|
|
startedAt: Date.now(),
|
|
events: [
|
|
{ kind: 'status', label: 'thinking' },
|
|
{ kind: 'thinking', text: 'Sketching the hero section layout' },
|
|
],
|
|
};
|
|
const state = buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [assistant],
|
|
streaming: true,
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
});
|
|
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',
|
|
role: 'assistant',
|
|
content: 'Partial work',
|
|
runStatus: 'canceled',
|
|
startedAt: Date.now() - 10_000,
|
|
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
|
|
};
|
|
const state = buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [assistant],
|
|
streaming: false,
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
});
|
|
expect(state?.phase).toBe('stopped');
|
|
expect(state?.failed).toBe(false);
|
|
expect(state?.retryTarget).toBeNull();
|
|
});
|
|
|
|
it('keeps a waiting surface when the agent is asking the user a question', () => {
|
|
const assistant: ChatMessage = {
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: 'A few quick questions:\n<question-form id="discovery" title="Brief">{"questions":[]}</question-form>',
|
|
runStatus: 'succeeded',
|
|
startedAt: Date.now() - 4_000,
|
|
events: [{ kind: 'text', text: '<question-form id="discovery">{"questions":[]}</question-form>' }],
|
|
};
|
|
const state = buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [assistant],
|
|
streaming: false,
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
});
|
|
expect(state?.phase).toBe('awaiting-input');
|
|
expect(state?.retryTarget).toBeNull();
|
|
});
|
|
|
|
it('returns null for a finished run that produced no question or preview', () => {
|
|
const assistant: ChatMessage = {
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: 'All done!',
|
|
runStatus: 'succeeded',
|
|
startedAt: Date.now() - 4_000,
|
|
events: [{ kind: 'text', text: 'All done!' }],
|
|
};
|
|
expect(
|
|
buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [assistant],
|
|
streaming: false,
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('builds a failed state with a retry target', () => {
|
|
const assistant: ChatMessage = {
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: '',
|
|
runStatus: 'failed',
|
|
startedAt: Date.now() - 8_000,
|
|
events: [{ kind: 'text', text: 'Model request failed' }],
|
|
};
|
|
const state = buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [assistant],
|
|
streaming: false,
|
|
activeTab: null,
|
|
projectFiles: [],
|
|
liveArtifacts: [],
|
|
conversationError: 'Network error',
|
|
});
|
|
expect(state?.phase).toBe('failed');
|
|
expect(state?.failed).toBe(true);
|
|
expect(state?.errorMessage).toBe('Network error');
|
|
expect(state?.retryTarget).toBe(assistant);
|
|
});
|
|
|
|
it('hides preview state once a preview tab is active', () => {
|
|
const assistant: ChatMessage = {
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: '',
|
|
runStatus: 'running',
|
|
startedAt: Date.now(),
|
|
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
|
|
};
|
|
expect(
|
|
buildGenerationPreviewState({
|
|
designSystemProject: false,
|
|
messages: [assistant],
|
|
streaming: true,
|
|
activeTab: 'index.html',
|
|
projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }],
|
|
liveArtifacts: [],
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
});
|