fix(web): remove generating-state takeover from the workspace preview

Drop the GenerationPreviewStage card that hijacked the right-side
preview during generation/awaiting/stopped/failed phases. The workspace
now stays on its original panel (Design Files browser / live preview)
throughout a run, so generation no longer swaps the surface out from
under the user.

Removes the component, its CSS module, the generation-preview runtime
model + tests, the now-unused FileWorkspace props (messages/artifactHtml/
conversationError/onRetry), and the generationPreview.* i18n keys across
all locales.
This commit is contained in:
qiongyu1999 2026-05-31 17:11:19 +08:00
parent 9bb787e173
commit f875f542c3
26 changed files with 0 additions and 1387 deletions

View file

@ -60,9 +60,6 @@ import {
parseSketchWorkspaceDocument, parseSketchWorkspaceDocument,
type SketchItem, type SketchItem,
} from './sketch-model'; } from './sketch-model';
import { GenerationPreviewStage } from './GenerationPreviewStage';
import { buildGenerationPreviewState } from '../runtime/generation-preview';
import type { ChatMessage } from '../types';
interface Props { interface Props {
projectId: string; projectId: string;
@ -113,10 +110,6 @@ interface Props {
githubConnected?: boolean; githubConnected?: boolean;
commentPortalId?: string; commentPortalId?: string;
onCommentModeChange?: (active: boolean) => void; onCommentModeChange?: (active: boolean) => void;
messages?: ChatMessage[];
artifactHtml?: string | null;
conversationError?: string | null;
onRetry?: (message: ChatMessage) => void;
// Active discovery question form, surfaced in the right-hand Questions tab // Active discovery question form, surfaced in the right-hand Questions tab
// instead of inline in the chat. Owned by ProjectView (derived from the // instead of inline in the chat. Owned by ProjectView (derived from the
// latest assistant message). // latest assistant message).
@ -255,10 +248,6 @@ export function FileWorkspace({
githubConnected, githubConnected,
commentPortalId, commentPortalId,
onCommentModeChange, onCommentModeChange,
messages = [],
artifactHtml,
conversationError,
onRetry,
questionForm = null, questionForm = null,
questionFormPreview = null, questionFormPreview = null,
questionFormKey = null, questionFormKey = null,
@ -313,21 +302,6 @@ export function FileWorkspace({
[liveArtifacts], [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 // 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 // (or on project switch). Fall back to the Design Files browser so a
// fresh project lands in a useful place. // fresh project lands in a useful place.
@ -1076,15 +1050,6 @@ export function FileWorkspace({
onConnectRepo={onConnectRepo} onConnectRepo={onConnectRepo}
githubConnected={githubConnected} githubConnected={githubConnected}
/> />
) : generationPreview ? (
<GenerationPreviewStage
model={generationPreview}
onRetry={
generationPreview.retryTarget && onRetry
? () => onRetry(generationPreview.retryTarget!)
: undefined
}
/>
) : activeTab === DESIGN_FILES_TAB ? ( ) : activeTab === DESIGN_FILES_TAB ? (
<DesignFilesPanel <DesignFilesPanel
key={projectId} key={projectId}

View file

@ -1,281 +0,0 @@
.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);
}
/* A gentle breathing halo so the card reads as "alive" while we wait,
even between discrete progress events. */
.mark[data-active='true'] {
animation: markBreathe 2.4s ease-in-out infinite;
}
.stage[data-phase='failed'] .mark {
border-color: color-mix(in srgb, var(--red) 32%, var(--border));
background: color-mix(in srgb, var(--red) 8%, var(--bg-panel));
color: var(--red);
}
.stage[data-phase='stopped'] .mark {
border-color: var(--border);
background: var(--bg-panel);
color: var(--text-muted);
}
@keyframes markBreathe {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 22%, transparent);
}
50% {
transform: scale(1.04);
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
}
}
.title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.lead {
margin: 0;
max-width: 420px;
min-height: 21px;
font-size: 14px;
line-height: 1.5;
color: var(--text-muted);
}
/* Live activity snippet: subtly de-emphasised + animated so streaming
updates feel continuous rather than abrupt. */
.lead[data-live='true'] {
color: var(--text-faint);
animation: leadFade 240ms ease-out;
}
@keyframes leadFade {
from {
opacity: 0.35;
}
to {
opacity: 1;
}
}
.progress {
position: relative;
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);
}
/* Indeterminate shimmer sweeping over the determinate fill so the bar
keeps moving even when the percentage holds steady between events. */
.progress[data-active='true']::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--accent) 45%, transparent),
transparent
);
transform: translateX(-100%);
animation: progressSweep 1.5s ease-in-out infinite;
}
.stage[data-phase='failed'] .progress span {
background: var(--red);
}
@keyframes progressSweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.steps {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin: 0;
padding: 0;
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;
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;
/* 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'] {
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;
}
.stepDot[data-running='true'] {
opacity: 1;
animation: stepPulse 1.1s ease-in-out infinite;
}
@keyframes stepPulse {
0%,
100% {
transform: scale(1);
opacity: 0.5;
}
50% {
transform: scale(1.5);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.mark[data-active='true'],
.progress[data-active='true']::after,
.stepDot[data-running='true'],
.lead[data-live='true'],
.step,
.substatus {
animation: none;
}
}
.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

@ -1,121 +0,0 @@
import { useT } from '../i18n';
import type { GenerationPreviewModel } 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 generating = model.phase === 'generating';
const stepLabels: Record<GenerationPreviewModel['steps'][number]['id'], string> = {
understand: t('generationPreview.stepUnderstand'),
generate: t('generationPreview.stepGenerate'),
prepare: t('generationPreview.stepPrepare'),
};
const title =
model.phase === 'failed'
? t('generationPreview.failedTitle')
: model.phase === 'stopped'
? t('generationPreview.stoppedTitle')
: model.phase === 'awaiting-input'
? t('generationPreview.awaitingTitle')
: t('generationPreview.title');
const lead =
model.phase === 'failed'
? model.errorMessage || t('generationPreview.failedFallback')
: model.phase === 'stopped'
? t('generationPreview.stoppedLead')
: model.phase === 'awaiting-input'
? t('generationPreview.awaitingLead')
: model.activityLabel;
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}
data-testid="generation-preview-stage"
data-phase={model.phase}
aria-live="polite"
aria-busy={generating}
>
<div className={styles.mark} data-active={generating} aria-hidden>
<Icon name={markIcon} size={24} />
</div>
<h1 className={styles.title}>{title}</h1>
{!showSubstatus && lead ? (
<p className={styles.lead} data-live={generating && Boolean(model.activityLabel)}>
{lead}
</p>
) : null}
<div
className={styles.progress}
data-active={generating}
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
.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' ? (
<Icon name="check" size={12} />
) : step.status === 'failed' ? (
<Icon name="close" size={12} />
) : (
<span className={styles.stepDot} data-running={step.status === 'running' && generating} />
)}
</span>
<span className={styles.stepLabel}>{stepLabels[step.id]}</span>
</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}
{model.phase === 'failed' && onRetry ? (
<button
type="button"
className={styles.retry}
data-testid="generation-preview-retry"
onClick={onRetry}
>
{t('generationPreview.retry')}
</button>
) : null}
</section>
);
}

View file

@ -4660,10 +4660,6 @@ export function ProjectView({
githubConnected={githubConnected} githubConnected={githubConnected}
commentPortalId={commentInspectorPortalId} commentPortalId={commentInspectorPortalId}
onCommentModeChange={setCommentInspectorActive} onCommentModeChange={setCommentInspectorActive}
messages={messages}
artifactHtml={artifact?.html}
conversationError={error}
onRetry={handleRetry}
questionForm={questionForm} questionForm={questionForm}
questionFormPreview={questionFormPreview} questionFormPreview={questionFormPreview}
questionFormKey={questionFormKey} questionFormKey={questionFormKey}

View file

@ -1816,19 +1816,4 @@ export const ar: Dict = {
'diagnostics.exporting': 'جارٍ التصدير…', 'diagnostics.exporting': 'جارٍ التصدير…',
'diagnostics.exportSuccess': 'تم حفظ التشخيص في {path}', 'diagnostics.exportSuccess': 'تم حفظ التشخيص في {path}',
'diagnostics.exportFailed': 'تعذّر تصدير التشخيص: {message}', 'diagnostics.exportFailed': 'تعذّر تصدير التشخيص: {message}',
'generationPreview.title': 'جارٍ الإنشاء…',
'generationPreview.failedTitle': 'فشل الإنشاء',
'generationPreview.failedFallback': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
'generationPreview.footnote': 'يستغرق عادةً من 2 إلى 5 دقائق',
'generationPreview.stepUnderstand': 'فهم المتطلبات',
'generationPreview.stepGenerate': 'إنشاء الصفحة',
'generationPreview.stepPrepare': 'تحضير المعاينة',
'generationPreview.elapsed': 'مضى {elapsed}',
'generationPreview.estimate': 'عادةً 25 دقائق',
'generationPreview.progressAria': 'تقدّم الإنشاء: {percent}%',
'generationPreview.retry': 'إعادة المحاولة',
'generationPreview.awaitingTitle': 'في انتظار ردّك',
'generationPreview.awaitingLead': 'أجب عن بعض الأسئلة في المحادثة للمتابعة.',
'generationPreview.stoppedTitle': 'تم إيقاف الإنشاء مؤقتًا',
'generationPreview.stoppedLead': 'تابع الخطوات المتبقية من المحادثة على اليسار.',
}; };

View file

@ -1753,19 +1753,4 @@ export const de: Dict = {
'diagnostics.exporting': 'Exportiere…', 'diagnostics.exporting': 'Exportiere…',
'diagnostics.exportSuccess': 'Diagnose gespeichert: {path}', 'diagnostics.exportSuccess': 'Diagnose gespeichert: {path}',
'diagnostics.exportFailed': 'Diagnose-Export fehlgeschlagen: {message}', 'diagnostics.exportFailed': 'Diagnose-Export fehlgeschlagen: {message}',
'generationPreview.title': 'Wird generiert…',
'generationPreview.failedTitle': 'Generierung fehlgeschlagen',
'generationPreview.failedFallback': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
'generationPreview.footnote': 'Dauert normalerweise 25 Minuten',
'generationPreview.stepUnderstand': 'Anforderungen verstehen',
'generationPreview.stepGenerate': 'Seite generieren',
'generationPreview.stepPrepare': 'Vorschau vorbereiten',
'generationPreview.elapsed': '{elapsed} vergangen',
'generationPreview.estimate': 'Normalerweise 25 Min.',
'generationPreview.progressAria': 'Generierungsfortschritt: {percent}%',
'generationPreview.retry': 'Erneut versuchen',
'generationPreview.awaitingTitle': 'Warten auf deine Eingabe',
'generationPreview.awaitingLead': 'Beantworte ein paar kurze Fragen im Chat, um fortzufahren.',
'generationPreview.stoppedTitle': 'Generierung pausiert',
'generationPreview.stoppedLead': 'Setze die verbleibenden Schritte im Chat links fort.',
}; };

View file

@ -1577,21 +1577,6 @@ export const en: Dict = {
'workspace.openFromDesignFiles': 'Open a file from', 'workspace.openFromDesignFiles': 'Open a file from',
'workspace.designFilesLink': 'Design Files', 'workspace.designFilesLink': 'Design Files',
'workspace.loadingSketch': 'Loading sketch…', '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',
'generationPreview.awaitingTitle': 'Waiting for your input',
'generationPreview.awaitingLead': 'Answer a few quick questions in the chat to continue.',
'generationPreview.stoppedTitle': 'Generation paused',
'generationPreview.stoppedLead': 'Resume the remaining steps from the chat on the left.',
'designFiles.title': 'Design Files', 'designFiles.title': 'Design Files',
'designFiles.upload': 'Upload files', 'designFiles.upload': 'Upload files',
'designFiles.pasteText': 'Paste as text file', 'designFiles.pasteText': 'Paste as text file',

View file

@ -1704,19 +1704,4 @@ export const esES: Dict = {
'diagnostics.exporting': 'Exportando…', 'diagnostics.exporting': 'Exportando…',
'diagnostics.exportSuccess': 'Diagnósticos guardados en {path}', 'diagnostics.exportSuccess': 'Diagnósticos guardados en {path}',
'diagnostics.exportFailed': 'No se pudieron exportar los diagnósticos: {message}', 'diagnostics.exportFailed': 'No se pudieron exportar los diagnósticos: {message}',
'generationPreview.title': 'Generando…',
'generationPreview.failedTitle': 'Error de generación',
'generationPreview.failedFallback': 'Algo salió mal. Inténtalo de nuevo.',
'generationPreview.footnote': 'Suele tardar de 2 a 5 minutos',
'generationPreview.stepUnderstand': 'Entendiendo los requisitos',
'generationPreview.stepGenerate': 'Generando la página',
'generationPreview.stepPrepare': 'Preparando la vista previa',
'generationPreview.elapsed': '{elapsed} transcurridos',
'generationPreview.estimate': 'Normalmente 25 min',
'generationPreview.progressAria': 'Progreso de la generación: {percent}%',
'generationPreview.retry': 'Reintentar',
'generationPreview.awaitingTitle': 'Esperando tu respuesta',
'generationPreview.awaitingLead': 'Responde unas preguntas en el chat para continuar.',
'generationPreview.stoppedTitle': 'Generación en pausa',
'generationPreview.stoppedLead': 'Reanuda los pasos restantes desde el chat de la izquierda.',
}; };

View file

@ -1858,19 +1858,4 @@ export const fa: Dict = {
'diagnostics.exporting': 'در حال صادر کردن…', 'diagnostics.exporting': 'در حال صادر کردن…',
'diagnostics.exportSuccess': 'تشخیص در {path} ذخیره شد', 'diagnostics.exportSuccess': 'تشخیص در {path} ذخیره شد',
'diagnostics.exportFailed': 'صادر کردن تشخیص ناموفق بود: {message}', 'diagnostics.exportFailed': 'صادر کردن تشخیص ناموفق بود: {message}',
'generationPreview.title': 'در حال ساخت…',
'generationPreview.failedTitle': 'ساخت ناموفق بود',
'generationPreview.failedFallback': 'مشکلی پیش آمد. لطفاً دوباره تلاش کنید.',
'generationPreview.footnote': 'معمولاً ۲ تا ۵ دقیقه طول می‌کشد',
'generationPreview.stepUnderstand': 'درک نیازمندی‌ها',
'generationPreview.stepGenerate': 'ساخت صفحه',
'generationPreview.stepPrepare': 'آماده‌سازی پیش‌نمایش',
'generationPreview.elapsed': '{elapsed} گذشته',
'generationPreview.estimate': 'معمولاً ۲ تا ۵ دقیقه',
'generationPreview.progressAria': 'پیشرفت ساخت: {percent}%',
'generationPreview.retry': 'تلاش دوباره',
'generationPreview.awaitingTitle': 'در انتظار پاسخ شما',
'generationPreview.awaitingLead': 'برای ادامه، به چند پرسش در گفتگو پاسخ دهید.',
'generationPreview.stoppedTitle': 'ساخت متوقف شد',
'generationPreview.stoppedLead': 'مراحل باقی‌مانده را از گفتگوی سمت چپ ادامه دهید.',
}; };

View file

@ -2465,19 +2465,4 @@ export const fr: Dict = {
'diagnostics.exporting': 'Exportation…', 'diagnostics.exporting': 'Exportation…',
'diagnostics.exportSuccess': 'Diagnostic enregistré dans {path}', 'diagnostics.exportSuccess': 'Diagnostic enregistré dans {path}',
'diagnostics.exportFailed': 'Impossible d\'exporter le diagnostic: {message}', 'diagnostics.exportFailed': 'Impossible d\'exporter le diagnostic: {message}',
'generationPreview.title': 'Génération…',
'generationPreview.failedTitle': 'Échec de la génération',
'generationPreview.failedFallback': 'Une erreur est survenue. Veuillez réessayer.',
'generationPreview.footnote': 'Prend généralement 2 à 5 minutes',
'generationPreview.stepUnderstand': 'Compréhension des besoins',
'generationPreview.stepGenerate': 'Génération de la page',
'generationPreview.stepPrepare': 'Préparation de l\'aperçu',
'generationPreview.elapsed': '{elapsed} écoulées',
'generationPreview.estimate': 'Généralement 25 min',
'generationPreview.progressAria': 'Progression de la génération : {percent}%',
'generationPreview.retry': 'Réessayer',
'generationPreview.awaitingTitle': 'En attente de votre réponse',
'generationPreview.awaitingLead': 'Répondez à quelques questions dans le chat pour continuer.',
'generationPreview.stoppedTitle': 'Génération en pause',
'generationPreview.stoppedLead': 'Reprenez les étapes restantes depuis le chat à gauche.',
}; };

View file

@ -1825,19 +1825,4 @@ export const hu: Dict = {
'diagnostics.exporting': 'Exportálás…', 'diagnostics.exporting': 'Exportálás…',
'diagnostics.exportSuccess': 'Diagnosztika mentve: {path}', 'diagnostics.exportSuccess': 'Diagnosztika mentve: {path}',
'diagnostics.exportFailed': 'Diagnosztika exportálása sikertelen: {message}', 'diagnostics.exportFailed': 'Diagnosztika exportálása sikertelen: {message}',
'generationPreview.title': 'Generálás…',
'generationPreview.failedTitle': 'A generálás sikertelen',
'generationPreview.failedFallback': 'Hiba történt. Kérjük, próbáld újra.',
'generationPreview.footnote': 'Általában 25 percet vesz igénybe',
'generationPreview.stepUnderstand': 'Követelmények értelmezése',
'generationPreview.stepGenerate': 'Oldal generálása',
'generationPreview.stepPrepare': 'Előnézet előkészítése',
'generationPreview.elapsed': '{elapsed} eltelt',
'generationPreview.estimate': 'Általában 25 perc',
'generationPreview.progressAria': 'Generálás állapota: {percent}%',
'generationPreview.retry': 'Újra',
'generationPreview.awaitingTitle': 'Várakozás a válaszodra',
'generationPreview.awaitingLead': 'Válaszolj néhány kérdésre a csevegésben a folytatáshoz.',
'generationPreview.stoppedTitle': 'Generálás szüneteltetve',
'generationPreview.stoppedLead': 'Folytasd a hátralévő lépéseket a bal oldali csevegésből.',
}; };

View file

@ -1858,19 +1858,4 @@ export const id: Dict = {
'diagnostics.exporting': 'Mengekspor…', 'diagnostics.exporting': 'Mengekspor…',
'diagnostics.exportSuccess': 'Diagnostik disimpan di {path}', 'diagnostics.exportSuccess': 'Diagnostik disimpan di {path}',
'diagnostics.exportFailed': 'Gagal mengekspor diagnostik: {message}', 'diagnostics.exportFailed': 'Gagal mengekspor diagnostik: {message}',
'generationPreview.title': 'Membuat…',
'generationPreview.failedTitle': 'Pembuatan gagal',
'generationPreview.failedFallback': 'Terjadi kesalahan. Silakan coba lagi.',
'generationPreview.footnote': 'Biasanya butuh 25 menit',
'generationPreview.stepUnderstand': 'Memahami kebutuhan',
'generationPreview.stepGenerate': 'Membuat halaman',
'generationPreview.stepPrepare': 'Menyiapkan pratinjau',
'generationPreview.elapsed': '{elapsed} berlalu',
'generationPreview.estimate': 'Biasanya 25 mnt',
'generationPreview.progressAria': 'Progres pembuatan: {percent}%',
'generationPreview.retry': 'Coba lagi',
'generationPreview.awaitingTitle': 'Menunggu masukan Anda',
'generationPreview.awaitingLead': 'Jawab beberapa pertanyaan di obrolan untuk melanjutkan.',
'generationPreview.stoppedTitle': 'Pembuatan dijeda',
'generationPreview.stoppedLead': 'Lanjutkan langkah yang tersisa dari obrolan di kiri.',
}; };

View file

@ -1684,19 +1684,4 @@ export const it: Dict = {
'liveArtifact.viewer.code.loading': 'Caricamento codice…', 'liveArtifact.viewer.code.loading': 'Caricamento codice…',
'liveArtifact.viewer.code.unavailable': 'Il codice non è ancora disponibile.', 'liveArtifact.viewer.code.unavailable': 'Il codice non è ancora disponibile.',
'liveArtifact.viewer.code.empty': 'Questo file di codice è vuoto.', 'liveArtifact.viewer.code.empty': 'Questo file di codice è vuoto.',
'generationPreview.title': 'Generazione…',
'generationPreview.failedTitle': 'Generazione non riuscita',
'generationPreview.failedFallback': 'Qualcosa è andato storto. Riprova.',
'generationPreview.footnote': 'Di solito richiede 25 minuti',
'generationPreview.stepUnderstand': 'Analisi dei requisiti',
'generationPreview.stepGenerate': 'Generazione della pagina',
'generationPreview.stepPrepare': 'Preparazione dell\'anteprima',
'generationPreview.elapsed': '{elapsed} trascorsi',
'generationPreview.estimate': 'Di solito 25 min',
'generationPreview.progressAria': 'Avanzamento della generazione: {percent}%',
'generationPreview.retry': 'Riprova',
'generationPreview.awaitingTitle': 'In attesa della tua risposta',
'generationPreview.awaitingLead': 'Rispondi ad alcune domande nella chat per continuare.',
'generationPreview.stoppedTitle': 'Generazione in pausa',
'generationPreview.stoppedLead': 'Riprendi i passaggi rimanenti dalla chat a sinistra.',
}; };

View file

@ -1752,19 +1752,4 @@ export const ja: Dict = {
'diagnostics.exporting': 'エクスポート中…', 'diagnostics.exporting': 'エクスポート中…',
'diagnostics.exportSuccess': '診断情報を {path} に保存しました', 'diagnostics.exportSuccess': '診断情報を {path} に保存しました',
'diagnostics.exportFailed': '診断情報のエクスポートに失敗しました: {message}', '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': '再試行',
'generationPreview.awaitingTitle': '入力をお待ちしています',
'generationPreview.awaitingLead': 'チャットでいくつかの質問に答えると続行します。',
'generationPreview.stoppedTitle': '生成を一時停止しました',
'generationPreview.stoppedLead': '左側のチャットから残りのステップを再開できます。',
}; };

View file

@ -1865,19 +1865,4 @@ export const ko: Dict = {
'diagnostics.exporting': '내보내는 중…', 'diagnostics.exporting': '내보내는 중…',
'diagnostics.exportSuccess': '진단 정보를 {path}에 저장했습니다', 'diagnostics.exportSuccess': '진단 정보를 {path}에 저장했습니다',
'diagnostics.exportFailed': '진단 정보 내보내기 실패: {message}', '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': '다시 시도',
'generationPreview.awaitingTitle': '입력을 기다리는 중',
'generationPreview.awaitingLead': '왼쪽 채팅에서 몇 가지 질문에 답하면 계속됩니다.',
'generationPreview.stoppedTitle': '생성이 일시중지됨',
'generationPreview.stoppedLead': '왼쪽 채팅에서 남은 단계를 다시 시작할 수 있습니다.',
}; };

View file

@ -1815,19 +1815,4 @@ export const pl: Dict = {
'diagnostics.exporting': 'Eksportowanie…', 'diagnostics.exporting': 'Eksportowanie…',
'diagnostics.exportSuccess': 'Diagnostyka zapisana w {path}', 'diagnostics.exportSuccess': 'Diagnostyka zapisana w {path}',
'diagnostics.exportFailed': 'Nie udało się wyeksportować diagnostyki: {message}', 'diagnostics.exportFailed': 'Nie udało się wyeksportować diagnostyki: {message}',
'generationPreview.title': 'Generowanie…',
'generationPreview.failedTitle': 'Generowanie nie powiodło się',
'generationPreview.failedFallback': 'Coś poszło nie tak. Spróbuj ponownie.',
'generationPreview.footnote': 'Zwykle trwa 25 minut',
'generationPreview.stepUnderstand': 'Analiza wymagań',
'generationPreview.stepGenerate': 'Generowanie strony',
'generationPreview.stepPrepare': 'Przygotowanie podglądu',
'generationPreview.elapsed': 'Upłynęło {elapsed}',
'generationPreview.estimate': 'Zwykle 25 min',
'generationPreview.progressAria': 'Postęp generowania: {percent}%',
'generationPreview.retry': 'Spróbuj ponownie',
'generationPreview.awaitingTitle': 'Oczekiwanie na Twoją odpowiedź',
'generationPreview.awaitingLead': 'Odpowiedz na kilka pytań na czacie, aby kontynuować.',
'generationPreview.stoppedTitle': 'Generowanie wstrzymane',
'generationPreview.stoppedLead': 'Wznów pozostałe kroki z czatu po lewej stronie.',
}; };

View file

@ -1856,19 +1856,4 @@ export const ptBR: Dict = {
'diagnostics.exporting': 'Exportando…', 'diagnostics.exporting': 'Exportando…',
'diagnostics.exportSuccess': 'Diagnósticos salvos em {path}', 'diagnostics.exportSuccess': 'Diagnósticos salvos em {path}',
'diagnostics.exportFailed': 'Falha ao exportar diagnósticos: {message}', 'diagnostics.exportFailed': 'Falha ao exportar diagnósticos: {message}',
'generationPreview.title': 'Gerando…',
'generationPreview.failedTitle': 'Falha na geração',
'generationPreview.failedFallback': 'Algo deu errado. Tente novamente.',
'generationPreview.footnote': 'Normalmente leva de 2 a 5 minutos',
'generationPreview.stepUnderstand': 'Entendendo os requisitos',
'generationPreview.stepGenerate': 'Gerando a página',
'generationPreview.stepPrepare': 'Preparando a prévia',
'generationPreview.elapsed': '{elapsed} decorridos',
'generationPreview.estimate': 'Normalmente 25 min',
'generationPreview.progressAria': 'Progresso da geração: {percent}%',
'generationPreview.retry': 'Tentar novamente',
'generationPreview.awaitingTitle': 'Aguardando sua resposta',
'generationPreview.awaitingLead': 'Responda algumas perguntas no chat para continuar.',
'generationPreview.stoppedTitle': 'Geração pausada',
'generationPreview.stoppedLead': 'Retome as etapas restantes pelo chat à esquerda.',
}; };

View file

@ -1856,19 +1856,4 @@ export const ru: Dict = {
'diagnostics.exporting': 'Экспортирование…', 'diagnostics.exporting': 'Экспортирование…',
'diagnostics.exportSuccess': 'Диагностика сохранена: {path}', 'diagnostics.exportSuccess': 'Диагностика сохранена: {path}',
'diagnostics.exportFailed': 'Не удалось экспортировать диагностику: {message}', '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': 'Повторить',
'generationPreview.awaitingTitle': 'Ожидание вашего ответа',
'generationPreview.awaitingLead': 'Ответьте на несколько вопросов в чате, чтобы продолжить.',
'generationPreview.stoppedTitle': 'Генерация приостановлена',
'generationPreview.stoppedLead': 'Продолжите оставшиеся шаги в чате слева.',
}; };

View file

@ -1645,19 +1645,4 @@ export const th: Dict = {
'settings.designSystemsCategory': 'หมวดหมู่', 'settings.designSystemsCategory': 'หมวดหมู่',
'settings.designSystemsAllCategories': 'ทุกหมวดหมู่', 'settings.designSystemsAllCategories': 'ทุกหมวดหมู่',
'settings.designSystemsShowInHomeGallery': 'แสดงในแกลเลอรีหน้าแรก', 'settings.designSystemsShowInHomeGallery': 'แสดงในแกลเลอรีหน้าแรก',
'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': 'ลองใหม่',
'generationPreview.awaitingTitle': 'กำลังรอข้อมูลจากคุณ',
'generationPreview.awaitingLead': 'ตอบคำถามสองสามข้อในแชทเพื่อดำเนินการต่อ',
'generationPreview.stoppedTitle': 'หยุดการสร้างชั่วคราว',
'generationPreview.stoppedLead': 'ดำเนินการขั้นตอนที่เหลือต่อจากแชทด้านซ้าย',
}; };

View file

@ -1802,19 +1802,4 @@ export const tr: Dict = {
'diagnostics.exporting': 'Dışa aktarılıyor…', 'diagnostics.exporting': 'Dışa aktarılıyor…',
'diagnostics.exportSuccess': 'Tanılama {path} konumuna kaydedildi', 'diagnostics.exportSuccess': 'Tanılama {path} konumuna kaydedildi',
'diagnostics.exportFailed': 'Tanılama dışa aktarılamadı: {message}', 'diagnostics.exportFailed': 'Tanılama dışa aktarılamadı: {message}',
'generationPreview.title': 'Oluşturuluyor…',
'generationPreview.failedTitle': 'Oluşturma başarısız',
'generationPreview.failedFallback': 'Bir şeyler ters gitti. Lütfen tekrar deneyin.',
'generationPreview.footnote': 'Genellikle 25 dakika sürer',
'generationPreview.stepUnderstand': 'Gereksinimler anlaşılıyor',
'generationPreview.stepGenerate': 'Sayfa oluşturuluyor',
'generationPreview.stepPrepare': 'Önizleme hazırlanıyor',
'generationPreview.elapsed': '{elapsed} geçti',
'generationPreview.estimate': 'Genellikle 25 dk',
'generationPreview.progressAria': 'Oluşturma ilerlemesi: %{percent}',
'generationPreview.retry': 'Yeniden dene',
'generationPreview.awaitingTitle': 'Yanıtınız bekleniyor',
'generationPreview.awaitingLead': 'Devam etmek için sohbette birkaç soruyu yanıtlayın.',
'generationPreview.stoppedTitle': 'Oluşturma duraklatıldı',
'generationPreview.stoppedLead': 'Kalan adımları soldaki sohbetten sürdürün.',
}; };

View file

@ -1858,19 +1858,4 @@ export const uk: Dict = {
'diagnostics.exporting': 'Експортування…', 'diagnostics.exporting': 'Експортування…',
'diagnostics.exportSuccess': 'Діагностику збережено: {path}', 'diagnostics.exportSuccess': 'Діагностику збережено: {path}',
'diagnostics.exportFailed': 'Не вдалося експортувати діагностику: {message}', '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': 'Повторити',
'generationPreview.awaitingTitle': 'Очікування вашої відповіді',
'generationPreview.awaitingLead': 'Дайте відповідь на кілька запитань у чаті, щоб продовжити.',
'generationPreview.stoppedTitle': 'Генерацію призупинено',
'generationPreview.stoppedLead': 'Продовжте решту кроків у чаті ліворуч.',
}; };

View file

@ -2573,19 +2573,4 @@ export const zhCN: Dict = {
'diagnostics.exporting': '导出中…', 'diagnostics.exporting': '导出中…',
'diagnostics.exportSuccess': '诊断日志已保存到 {path}', 'diagnostics.exportSuccess': '诊断日志已保存到 {path}',
'diagnostics.exportFailed': '导出诊断日志失败:{message}', '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': '重试',
'generationPreview.awaitingTitle': '等待你的补充',
'generationPreview.awaitingLead': '在左侧聊天里回答几个问题即可继续生成。',
'generationPreview.stoppedTitle': '生成已暂停',
'generationPreview.stoppedLead': '可在左侧聊天里继续未完成的步骤。',
}; };

View file

@ -2135,19 +2135,4 @@ export const zhTW: Dict = {
'skillPluginCandidate.publishRepo': '發布倉庫', 'skillPluginCandidate.publishRepo': '發布倉庫',
'skillPluginCandidate.dismiss': '忽略', 'skillPluginCandidate.dismiss': '忽略',
'skillPluginCandidate.repoDescription': '這個倉庫看起來可以做成外掛。', 'skillPluginCandidate.repoDescription': '這個倉庫看起來可以做成外掛。',
'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': '重試',
'generationPreview.awaitingTitle': '等待你的補充',
'generationPreview.awaitingLead': '在左側聊天裡回答幾個問題即可繼續生成。',
'generationPreview.stoppedTitle': '生成已暫停',
'generationPreview.stoppedLead': '可在左側聊天裡繼續未完成的步驟。',
}; };

View file

@ -1914,21 +1914,6 @@ export interface Dict {
'workspace.openFromDesignFiles': string; 'workspace.openFromDesignFiles': string;
'workspace.designFilesLink': string; 'workspace.designFilesLink': string;
'workspace.loadingSketch': 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;
'generationPreview.awaitingTitle': string;
'generationPreview.awaitingLead': string;
'generationPreview.stoppedTitle': string;
'generationPreview.stoppedLead': string;
'designFiles.title': string; 'designFiles.title': string;
'designFiles.upload': string; 'designFiles.upload': string;
'designFiles.pasteText': string; 'designFiles.pasteText': string;

View file

@ -1,335 +0,0 @@
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';
export type GenerationPhase = 'generating' | 'awaiting-input' | 'stopped' | 'failed';
export interface GenerationPreviewStep {
id: 'understand' | 'generate' | 'prepare';
status: GenerationStepStatus;
}
export interface GenerationPreviewModel {
startedAt: number;
steps: GenerationPreviewStep[];
phase: GenerationPhase;
failed: boolean;
errorMessage: string | null;
progressPercent: number;
/**
* Latest human-readable activity snippet pulled from the streamed
* events. Only set while actively generating so the waiting surface
* 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: {
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) => liveArtifactTabId(entry.id) === 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 status = latestAssistant.runStatus;
const runActive = isActiveRunStatus(status) || input.streaming;
const runFailed = status === 'failed';
const runStopped = status === 'canceled';
// The agent finished its turn but is waiting on the user to answer an
// inline question form before it can keep going.
const awaitingInput =
!runActive && !runFailed && !runStopped && messageHasPendingQuestion(latestAssistant);
let phase: GenerationPhase;
if (runFailed) {
phase = 'failed';
} else if (runActive) {
phase = 'generating';
} else if (runStopped) {
phase = 'stopped';
} else if (awaitingInput) {
phase = 'awaiting-input';
} else {
return null;
}
// Once the user has something previewable, only the error state takes
// over the surface; the calmer waiting states defer to the live preview
// so we never hide a finished artifact behind a status card.
if (hasPreviewSurface && phase !== 'failed') return null;
const failed = phase === 'failed';
const events = latestAssistant.events ?? [];
const derived = deriveGenerationPreviewModel({
events,
hasArtifactHtml: Boolean(input.artifactHtml?.trim()),
hasPreviewSurface,
failed,
errorMessage: input.conversationError,
});
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,
phase,
failed,
errorMessage: derived.errorMessage,
progressPercent: derived.progressPercent,
activityLabel: generating ? latestActivityLabel(events) : null,
detailLabel: generating ? generationDetailLabel(events, todos) : null,
todoProgress,
retryTarget: failed ? 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']) || 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';
}
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)));
}
function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'queued' || status === 'running';
}
function messageHasPendingQuestion(message: ChatMessage): boolean {
if (typeof message.content === 'string' && QUESTION_FORM_RE.test(message.content)) {
return true;
}
const events = message.events ?? [];
return events.some((event) => event.kind === 'text' && QUESTION_FORM_RE.test(event.text));
}
function latestActivityLabel(events: AgentEvent[]): string | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index]!;
if (event.kind === 'thinking' && event.text.trim()) {
return truncateActivity(event.text);
}
if (event.kind === 'text' && event.text.trim() && !QUESTION_FORM_RE.test(event.text)) {
return truncateActivity(event.text);
}
// 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;
}
function truncateActivity(text: string): string {
const collapsed = text.replace(/\s+/g, ' ').trim();
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(
(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

@ -1,311 +0,0 @@
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();
});
});