mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +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>
This commit is contained in:
parent
ae22e9f851
commit
0450256ae5
9 changed files with 616 additions and 0 deletions
|
|
@ -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')}{' '}
|
||||
|
|
|
|||
146
apps/web/src/components/GenerationPreviewStage.module.css
Normal file
146
apps/web/src/components/GenerationPreviewStage.module.css
Normal 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;
|
||||
}
|
||||
97
apps/web/src/components/GenerationPreviewStage.tsx
Normal file
97
apps/web/src/components/GenerationPreviewStage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4513,6 +4513,10 @@ export function ProjectView({
|
|||
githubConnected={githubConnected}
|
||||
commentPortalId={commentInspectorPortalId}
|
||||
onCommentModeChange={setCommentInspectorActive}
|
||||
messages={messages}
|
||||
artifactHtml={artifact?.html}
|
||||
conversationError={error}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
</div>
|
||||
{projectActionsToast ? (
|
||||
|
|
|
|||
|
|
@ -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 2–5 minutes',
|
||||
'generationPreview.stepUnderstand': 'Understanding requirements',
|
||||
'generationPreview.stepGenerate': 'Generating page',
|
||||
'generationPreview.stepPrepare': 'Preparing preview',
|
||||
'generationPreview.elapsed': '{elapsed} elapsed',
|
||||
'generationPreview.estimate': 'Usually 2–5 min',
|
||||
'generationPreview.progressAria': 'Generation progress: {percent}%',
|
||||
'generationPreview.retry': 'Retry',
|
||||
'designFiles.title': 'Design Files',
|
||||
'designFiles.upload': 'Upload files',
|
||||
'designFiles.pasteText': 'Paste as text 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': '通常需要 2–5 分钟',
|
||||
'generationPreview.stepUnderstand': '理解需求',
|
||||
'generationPreview.stepGenerate': '生成页面',
|
||||
'generationPreview.stepPrepare': '准备预览',
|
||||
'generationPreview.elapsed': '已等待 {elapsed}',
|
||||
'generationPreview.estimate': '通常需要 2–5 分钟',
|
||||
'generationPreview.progressAria': '生成进度:{percent}%',
|
||||
'generationPreview.retry': '重试',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
205
apps/web/src/runtime/generation-preview.ts
Normal file
205
apps/web/src/runtime/generation-preview.ts
Normal 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;
|
||||
}
|
||||
96
apps/web/tests/runtime/generation-preview.test.ts
Normal file
96
apps/web/tests/runtime/generation-preview.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue