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>
This commit is contained in:
chaoxiaoche 2026-05-28 22:14:31 +08:00
parent ae22e9f851
commit 0450256ae5
9 changed files with 616 additions and 0 deletions

View file

@ -58,6 +58,9 @@ import {
parseSketchWorkspaceDocument,
type SketchItem,
} from './sketch-model';
import { GenerationPreviewStage } from './GenerationPreviewStage';
import { buildGenerationPreviewState } from '../runtime/generation-preview';
import type { ChatMessage } from '../types';
interface Props {
projectId: string;
@ -108,6 +111,10 @@ interface Props {
githubConnected?: boolean;
commentPortalId?: string;
onCommentModeChange?: (active: boolean) => void;
messages?: ChatMessage[];
artifactHtml?: string | null;
conversationError?: string | null;
onRetry?: (message: ChatMessage) => void;
}
interface SketchState {
@ -226,6 +233,10 @@ export function FileWorkspace({
githubConnected,
commentPortalId,
onCommentModeChange,
messages = [],
artifactHtml,
conversationError,
onRetry,
}: Props) {
const t = useT();
const analytics = useAnalytics();
@ -270,6 +281,21 @@ export function FileWorkspace({
[liveArtifacts],
);
const generationPreview = useMemo(
() =>
buildGenerationPreviewState({
designSystemProject: Boolean(designSystemProject),
messages,
streaming: Boolean(streaming),
activeTab,
projectFiles: visibleFiles,
liveArtifacts,
artifactHtml,
conversationError,
}),
[designSystemProject, messages, streaming, activeTab, visibleFiles, liveArtifacts, artifactHtml, conversationError],
);
// Pull the persisted active tab in when the parent's hydration completes
// (or on project switch). Fall back to the Design Files browser so a
// fresh project lands in a useful place.
@ -1072,6 +1098,15 @@ export function FileWorkspace({
commentPortalId={commentPortalId}
onCommentModeChange={onCommentModeChange}
/>
) : generationPreview ? (
<GenerationPreviewStage
model={generationPreview}
onRetry={
generationPreview.retryTarget && onRetry
? () => onRetry(generationPreview.retryTarget!)
: undefined
}
/>
) : (
<div className="viewer-empty">
{t('workspace.openFromDesignFiles')}{' '}

View file

@ -0,0 +1,146 @@
.stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
min-height: 100%;
padding: 48px 32px;
text-align: center;
background: var(--bg);
}
.mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
color: var(--accent);
}
.title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.lead {
margin: 0;
max-width: 420px;
font-size: 14px;
line-height: 1.5;
color: var(--text-muted);
}
.progress {
width: min(360px, 100%);
height: 4px;
border-radius: 999px;
background: var(--border-soft);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
min-width: 8px;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 24%, transparent));
transition: width 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.steps {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.step {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12px;
}
.step[data-status='running'] {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
color: var(--text);
}
.step[data-status='succeeded'] {
border-color: color-mix(in srgb, var(--green) 36%, var(--border));
color: var(--green);
}
.step[data-status='failed'] {
border-color: color-mix(in srgb, var(--red) 40%, var(--border));
color: var(--red);
}
.stepIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.stepDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.55;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: var(--text-faint);
}
.metaDivider {
opacity: 0.6;
}
.retry {
margin-top: 4px;
min-height: 34px;
padding: 0 16px;
border: none;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--accent-contrast, #fff);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.retry:hover {
opacity: 0.92;
}
.retry:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

View file

@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useT } from '../i18n';
import type { GenerationPreviewModel } from '../runtime/generation-preview';
import { formatGenerationElapsed } from '../runtime/generation-preview';
import { Icon } from './Icon';
import styles from './GenerationPreviewStage.module.css';
type Props = {
model: GenerationPreviewModel;
onRetry?: (() => void) | undefined;
};
export function GenerationPreviewStage({ model, onRetry }: Props) {
const t = useT();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (model.failed) return undefined;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [model.failed, model.startedAt]);
const elapsedSec = Math.max(0, Math.round((now - model.startedAt) / 1000));
const elapsedLabel = formatGenerationElapsed(elapsedSec);
const stepLabels: Record<GenerationPreviewModel['steps'][number]['id'], string> = {
understand: t('generationPreview.stepUnderstand'),
generate: t('generationPreview.stepGenerate'),
prepare: t('generationPreview.stepPrepare'),
};
return (
<section
className={styles.stage}
data-testid="generation-preview-stage"
aria-live="polite"
aria-busy={!model.failed}
>
<div className={styles.mark} aria-hidden>
<Icon name="sparkles" size={24} />
</div>
<h1 className={styles.title}>
{model.failed ? t('generationPreview.failedTitle') : t('generationPreview.title')}
</h1>
<p className={styles.lead}>
{model.failed
? model.errorMessage || t('generationPreview.failedFallback')
: t('generationPreview.footnote')}
</p>
<div
className={styles.progress}
role="progressbar"
aria-label={t('generationPreview.progressAria', { percent: model.progressPercent })}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={model.progressPercent}
>
<span style={{ width: `${model.progressPercent}%` }} />
</div>
<ol className={styles.steps}>
{model.steps.map((step) => (
<li key={step.id} className={styles.step} data-status={step.status}>
<span className={styles.stepIcon} aria-hidden>
{step.status === 'succeeded' ? (
<Icon name="check" size={12} />
) : step.status === 'failed' ? (
<Icon name="close" size={12} />
) : (
<span className={styles.stepDot} />
)}
</span>
<span className={styles.stepLabel}>{stepLabels[step.id]}</span>
</li>
))}
</ol>
<div className={styles.meta}>
<span data-testid="generation-preview-elapsed">
{t('generationPreview.elapsed', { elapsed: elapsedLabel })}
</span>
<span className={styles.metaDivider} aria-hidden>
·
</span>
<span>{t('generationPreview.estimate')}</span>
</div>
{model.failed && onRetry ? (
<button
type="button"
className={styles.retry}
data-testid="generation-preview-retry"
onClick={onRetry}
>
{t('generationPreview.retry')}
</button>
) : null}
</section>
);
}

View file

@ -4513,6 +4513,10 @@ export function ProjectView({
githubConnected={githubConnected}
commentPortalId={commentInspectorPortalId}
onCommentModeChange={setCommentInspectorActive}
messages={messages}
artifactHtml={artifact?.html}
conversationError={error}
onRetry={handleRetry}
/>
</div>
{projectActionsToast ? (

View file

@ -1517,6 +1517,17 @@ export const en: Dict = {
'workspace.openFromDesignFiles': 'Open a file from',
'workspace.designFilesLink': 'Design Files',
'workspace.loadingSketch': 'Loading sketch…',
'generationPreview.title': 'Generating…',
'generationPreview.failedTitle': 'Generation failed',
'generationPreview.failedFallback': 'Something went wrong. Please try again.',
'generationPreview.footnote': 'Usually takes 25 minutes',
'generationPreview.stepUnderstand': 'Understanding requirements',
'generationPreview.stepGenerate': 'Generating page',
'generationPreview.stepPrepare': 'Preparing preview',
'generationPreview.elapsed': '{elapsed} elapsed',
'generationPreview.estimate': 'Usually 25 min',
'generationPreview.progressAria': 'Generation progress: {percent}%',
'generationPreview.retry': 'Retry',
'designFiles.title': 'Design Files',
'designFiles.upload': 'Upload files',
'designFiles.pasteText': 'Paste as text file',

View file

@ -2466,4 +2466,15 @@ export const zhCN: Dict = {
'diagnostics.exporting': '导出中…',
'diagnostics.exportSuccess': '诊断日志已保存到 {path}',
'diagnostics.exportFailed': '导出诊断日志失败:{message}',
'generationPreview.title': '正在生成…',
'generationPreview.failedTitle': '生成失败',
'generationPreview.failedFallback': '出现错误,请重试。',
'generationPreview.footnote': '通常需要 25 分钟',
'generationPreview.stepUnderstand': '理解需求',
'generationPreview.stepGenerate': '生成页面',
'generationPreview.stepPrepare': '准备预览',
'generationPreview.elapsed': '已等待 {elapsed}',
'generationPreview.estimate': '通常需要 25 分钟',
'generationPreview.progressAria': '生成进度:{percent}%',
'generationPreview.retry': '重试',
};

View file

@ -1833,6 +1833,17 @@ export interface Dict {
'workspace.openFromDesignFiles': string;
'workspace.designFilesLink': string;
'workspace.loadingSketch': string;
'generationPreview.title': string;
'generationPreview.failedTitle': string;
'generationPreview.failedFallback': string;
'generationPreview.footnote': string;
'generationPreview.stepUnderstand': string;
'generationPreview.stepGenerate': string;
'generationPreview.stepPrepare': string;
'generationPreview.elapsed': string;
'generationPreview.estimate': string;
'generationPreview.progressAria': string;
'generationPreview.retry': string;
'designFiles.title': string;
'designFiles.upload': string;
'designFiles.pasteText': string;

View file

@ -0,0 +1,205 @@
import type { AgentEvent, ChatMessage, LiveArtifactSummary, ProjectFile } from '../types';
import { isLiveArtifactTabId } from '../types';
export type GenerationStepStatus = 'pending' | 'running' | 'succeeded' | 'failed';
export interface GenerationPreviewStep {
id: 'understand' | 'generate' | 'prepare';
status: GenerationStepStatus;
}
export interface GenerationPreviewModel {
startedAt: number;
steps: GenerationPreviewStep[];
failed: boolean;
errorMessage: string | null;
progressPercent: number;
}
const PREVIEWABLE_FILE = /\.(html?|jsx|tsx|svg|md|pdf|pptx?|key)$/i;
export function workspaceHasPreviewSurface(input: {
activeTab: string | null;
projectFiles: ProjectFile[];
liveArtifacts: LiveArtifactSummary[];
streamingArtifactHtml?: string | null | undefined;
}): boolean {
if (input.streamingArtifactHtml?.trim()) return true;
const active = input.activeTab;
if (!active) return false;
if (isLiveArtifactTabId(active)) {
return input.liveArtifacts.some((entry) => entry.tabId === active);
}
const file = input.projectFiles.find((item) => item.name === active);
if (!file) return false;
if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio' || file.kind === 'sketch') {
return true;
}
if (PREVIEWABLE_FILE.test(file.name)) return true;
return file.kind === 'html' || file.kind === 'code' || file.kind === 'text';
}
export function deriveGenerationPreviewModel(input: {
events: AgentEvent[];
hasArtifactHtml: boolean;
hasPreviewSurface: boolean;
failed: boolean;
errorMessage?: string | null;
}): Pick<GenerationPreviewModel, 'steps' | 'progressPercent' | 'errorMessage'> {
const steps = derivePrototypeGenerationSteps({
events: input.events,
hasArtifactHtml: input.hasArtifactHtml,
hasPreviewSurface: input.hasPreviewSurface,
failed: input.failed,
});
const progressPercent = generationPreviewProgress(steps);
return {
steps,
progressPercent,
errorMessage: input.failed ? input.errorMessage?.trim() || failureMessageFromEvents(input.events) : null,
};
}
export function buildGenerationPreviewState(input: {
designSystemProject: boolean;
messages: ChatMessage[];
streaming: boolean;
activeTab: string | null;
projectFiles: ProjectFile[];
liveArtifacts: LiveArtifactSummary[];
artifactHtml?: string | null;
conversationError?: string | null;
}): (GenerationPreviewModel & { retryTarget: ChatMessage | null }) | null {
if (input.designSystemProject) return null;
const hasPreviewSurface = workspaceHasPreviewSurface({
activeTab: input.activeTab,
projectFiles: input.projectFiles,
liveArtifacts: input.liveArtifacts,
streamingArtifactHtml: input.artifactHtml,
});
const latestAssistant = [...input.messages]
.reverse()
.find((message) => message.role === 'assistant');
if (!latestAssistant) return null;
const runActive = isActiveRunStatus(latestAssistant.runStatus) || input.streaming;
const runFailed = latestAssistant.runStatus === 'failed';
if (!runActive && !runFailed) return null;
if (hasPreviewSurface && !runFailed) return null;
const events = latestAssistant.events ?? [];
const derived = deriveGenerationPreviewModel({
events,
hasArtifactHtml: Boolean(input.artifactHtml?.trim()),
hasPreviewSurface,
failed: runFailed,
errorMessage: input.conversationError,
});
const startedAt = latestAssistant.startedAt ?? latestAssistant.createdAt ?? Date.now();
return {
startedAt,
steps: derived.steps,
failed: runFailed,
errorMessage: derived.errorMessage,
progressPercent: derived.progressPercent,
retryTarget: runFailed ? latestAssistant : null,
};
}
export function derivePrototypeGenerationSteps(input: {
events: AgentEvent[];
hasArtifactHtml: boolean;
hasPreviewSurface: boolean;
failed: boolean;
}): GenerationPreviewStep[] {
const hasStatus = (labels: string[]) =>
eventsHaveStatus(input.events, labels);
const hasToolUse = input.events.some((event) => event.kind === 'tool_use');
const hasWriteLikeTool = input.events.some(
(event) =>
event.kind === 'tool_use'
&& typeof event.name === 'string'
&& /^(write|edit|bash|run_terminal_cmd)$/i.test(event.name),
);
const hasArtifactStart = input.events.some(
(event) => event.kind === 'text' && event.text.includes('<artifact'),
) || input.hasArtifactHtml;
const hasText = input.events.some((event) => event.kind === 'text' && event.text.trim().length > 0);
let understand: GenerationStepStatus = 'running';
if (input.failed && !hasText && !hasToolUse) {
understand = 'failed';
} else if (hasText || hasStatus(['thinking', 'streaming', 'requesting', 'starting']) || hasToolUse) {
understand = 'succeeded';
}
let generate: GenerationStepStatus = 'pending';
if (understand === 'succeeded') {
generate = 'running';
}
if (hasWriteLikeTool || hasArtifactStart) {
generate = 'succeeded';
}
if (input.failed && understand === 'succeeded' && !hasWriteLikeTool && !hasArtifactStart) {
generate = 'failed';
}
let prepare: GenerationStepStatus = 'pending';
if (generate === 'succeeded') {
prepare = 'running';
}
if (input.hasPreviewSurface || input.hasArtifactHtml) {
prepare = 'succeeded';
}
if (input.failed && generate === 'succeeded' && !input.hasPreviewSurface && !input.hasArtifactHtml) {
prepare = 'failed';
}
return [
{ id: 'understand', status: understand },
{ id: 'generate', status: generate },
{ id: 'prepare', status: prepare },
];
}
export function generationPreviewProgress(steps: GenerationPreviewStep[]): number {
if (steps.length === 0) return 8;
const weights = { pending: 0, running: 0.45, succeeded: 1, failed: 0.2 };
const score = steps.reduce((sum, step) => sum + weights[step.status], 0) / steps.length;
return Math.max(8, Math.min(steps.some((step) => step.status === 'failed') ? 72 : 92, Math.round(score * 100)));
}
export function formatGenerationElapsed(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
if (safe < 60) return `${safe}s`;
const minutes = Math.floor(safe / 60);
const remainder = safe % 60;
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
}
function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'queued' || status === 'running';
}
function eventsHaveStatus(events: AgentEvent[], labels: string[]): boolean {
const normalized = new Set(labels.map((label) => label.toLowerCase()));
return events.some(
(event) =>
event.kind === 'status'
&& normalized.has(event.label.toLowerCase()),
);
}
function failureMessageFromEvents(events: AgentEvent[]): string | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index]!;
if (event.kind === 'text' && event.text.trim()) return event.text.trim();
if (event.kind === 'status' && event.detail?.trim()) return event.detail.trim();
}
return null;
}

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
buildGenerationPreviewState,
derivePrototypeGenerationSteps,
formatGenerationElapsed,
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('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?.steps[0]?.status).toBe('running');
expect(state?.retryTarget).toBeNull();
});
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();
});
it('formats elapsed durations for the meta row', () => {
expect(formatGenerationElapsed(42)).toBe('42s');
expect(formatGenerationElapsed(125)).toBe('2m 5s');
});
});