From df1535b7fd405331170e78ee0c0120e08d434d06 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Sat, 30 May 2026 21:59:49 +0800 Subject: [PATCH] feat(web): add staged preview feedback during generation (#3227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(web): keep generation preview alive and persistent across waiting states Address UX feedback on the generation preview surface: - Make the waiting card feel alive instead of frozen: breathing mark, sweeping progress shimmer, pulsing running-step dot, and a live activity snippet pulled from streamed events (respects prefers-reduced-motion). - Add an `awaiting-input` phase so the preview no longer reverts to the empty "design will appear here" placeholder when the agent asks the user a clarifying question (detects inline ). - Add a `stopped` phase so a canceled/paused run keeps a contextual paused card instead of blanking the surface. - Fix workspaceHasPreviewSurface live-artifact tab match (was reading a non-existent `tabId` field) and correct the unit assertion that contradicted the helper's `thinking` handling. - Populate generationPreview.* keys (incl. new awaiting/stopped strings) across all locales. Co-authored-by: Cursor * feat(web): reveal generation steps progressively as the agent reaches them - Only render steps the agent has actually reached (drop pending pills) with a slide/fade entrance, so the card visibly evolves 1->2->3 instead of always showing the same fully-populated row. - Keep the "understand" step in progress during requesting/starting so a fresh run opens with a single step rather than a pre-filled set. - Stop surfacing status detail (e.g. the model slug from `requesting`) as the live activity line; only genuine thinking/output text is shown. Co-authored-by: Cursor * feat(web): add dynamic sub-status to the generating step Keep 3 high-level steps but give the long "generating" phase concrete, moving feedback (option A) instead of splitting into more, less-reliable steps: - Derive a sub-status from the agent's TodoWrite plan: the in-progress task label (activeForm) plus a done/total count, falling back to the latest write/edit target file when no plan was emitted. - The count counts the in-progress task toward `done` to match the chat-side todo card (e.g. 3/7 on both sides). - Suppress the higher-level narration line while the sub-status is shown so only one dynamic line appears at a time (early phase = narration, writing phase = concrete task + count). Co-authored-by: Cursor * feat(web): drop elapsed timer and duplicate estimate from generation preview The "usually 2–5 minutes" estimate showed twice (lead footnote + meta row) and the elapsed counter added little signal, so remove both: delete the meta row, stop falling back to the estimate footnote in the generating lead (render the lead only when live narration exists), and drop the now unused elapsed timer/util. Co-authored-by: Cursor --------- Co-authored-by: chaoxiaoche Co-authored-by: Cursor --- apps/web/src/components/FileWorkspace.tsx | 35 ++ .../GenerationPreviewStage.module.css | 281 +++++++++++++++ .../src/components/GenerationPreviewStage.tsx | 121 +++++++ apps/web/src/components/ProjectView.tsx | 4 + apps/web/src/i18n/locales/ar.ts | 15 + apps/web/src/i18n/locales/de.ts | 15 + apps/web/src/i18n/locales/en.ts | 15 + apps/web/src/i18n/locales/es-ES.ts | 15 + apps/web/src/i18n/locales/fa.ts | 15 + apps/web/src/i18n/locales/fr.ts | 15 + apps/web/src/i18n/locales/hu.ts | 15 + apps/web/src/i18n/locales/id.ts | 15 + apps/web/src/i18n/locales/it.ts | 15 + apps/web/src/i18n/locales/ja.ts | 15 + apps/web/src/i18n/locales/ko.ts | 15 + apps/web/src/i18n/locales/pl.ts | 15 + apps/web/src/i18n/locales/pt-BR.ts | 15 + apps/web/src/i18n/locales/ru.ts | 15 + apps/web/src/i18n/locales/th.ts | 15 + apps/web/src/i18n/locales/tr.ts | 15 + apps/web/src/i18n/locales/uk.ts | 15 + apps/web/src/i18n/locales/zh-CN.ts | 15 + apps/web/src/i18n/locales/zh-TW.ts | 15 + apps/web/src/i18n/types.ts | 15 + apps/web/src/runtime/generation-preview.ts | 335 ++++++++++++++++++ .../tests/runtime/generation-preview.test.ts | 311 ++++++++++++++++ 26 files changed, 1387 insertions(+) create mode 100644 apps/web/src/components/GenerationPreviewStage.module.css create mode 100644 apps/web/src/components/GenerationPreviewStage.tsx create mode 100644 apps/web/src/runtime/generation-preview.ts create mode 100644 apps/web/tests/runtime/generation-preview.test.ts diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 5dfdd6b1f..3742822f5 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -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. @@ -972,6 +998,15 @@ export function FileWorkspace({ onConnectRepo={onConnectRepo} githubConnected={githubConnected} /> + ) : generationPreview ? ( + onRetry(generationPreview.retryTarget!) + : undefined + } + /> ) : activeTab === DESIGN_FILES_TAB ? ( void) | undefined; +}; + +export function GenerationPreviewStage({ model, onRetry }: Props) { + const t = useT(); + + const generating = model.phase === 'generating'; + + const stepLabels: Record = { + 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 ( +
+
+ +
+

{title}

+ {!showSubstatus && lead ? ( +

+ {lead} +

+ ) : null} +
+ +
+
    + {model.steps + .filter((step) => step.status !== 'pending') + .map((step) => ( +
  1. + + {step.status === 'succeeded' ? ( + + ) : step.status === 'failed' ? ( + + ) : ( + + )} + + {stepLabels[step.id]} +
  2. + ))} +
+ {generating && (model.detailLabel || model.todoProgress) ? ( +
+ {model.detailLabel ? ( + {model.detailLabel} + ) : null} + {model.todoProgress ? ( + + {model.todoProgress.done}/{model.todoProgress.total} + + ) : null} +
+ ) : null} + {model.phase === 'failed' && onRetry ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 0946629d1..9a52d7ff2 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -4580,6 +4580,10 @@ export function ProjectView({ githubConnected={githubConnected} commentPortalId={commentInspectorPortalId} onCommentModeChange={setCommentInspectorActive} + messages={messages} + artifactHtml={artifact?.html} + conversationError={error} + onRetry={handleRetry} /> {projectActionsToast ? ( diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 7266e3802..2a0b82841 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -1799,4 +1799,19 @@ export const ar: 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': 'إعادة المحاولة', + 'generationPreview.awaitingTitle': 'في انتظار ردّك', + 'generationPreview.awaitingLead': 'أجب عن بعض الأسئلة في المحادثة للمتابعة.', + 'generationPreview.stoppedTitle': 'تم إيقاف الإنشاء مؤقتًا', + 'generationPreview.stoppedLead': 'تابع الخطوات المتبقية من المحادثة على اليسار.', }; diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 4d8d8d2de..908d3b350 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -1736,4 +1736,19 @@ export const de: Dict = { 'diagnostics.exporting': 'Exportiere…', 'diagnostics.exportSuccess': 'Diagnose gespeichert: {path}', '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 2–5 Minuten', + 'generationPreview.stepUnderstand': 'Anforderungen verstehen', + 'generationPreview.stepGenerate': 'Seite generieren', + 'generationPreview.stepPrepare': 'Vorschau vorbereiten', + 'generationPreview.elapsed': '{elapsed} vergangen', + 'generationPreview.estimate': 'Normalerweise 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index b86497e99..adfa2de87 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1574,6 +1574,21 @@ 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', + '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.upload': 'Upload files', 'designFiles.pasteText': 'Paste as text file', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 9906bd8f3..c6882b779 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -1687,4 +1687,19 @@ export const esES: Dict = { 'diagnostics.exporting': 'Exportando…', 'diagnostics.exportSuccess': 'Diagnósticos guardados en {path}', '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 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index c7ad1d549..0e7624f51 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -1841,4 +1841,19 @@ export const fa: Dict = { 'diagnostics.exporting': 'در حال صادر کردن…', 'diagnostics.exportSuccess': 'تشخیص در {path} ذخیره شد', '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': 'مراحل باقی‌مانده را از گفتگوی سمت چپ ادامه دهید.', }; diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 89780a4db..ac066d69e 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -2448,4 +2448,19 @@ export const fr: Dict = { 'diagnostics.exporting': 'Exportation…', 'diagnostics.exportSuccess': 'Diagnostic enregistré dans {path}', '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 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index 41918ead4..eb60effbd 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -1808,4 +1808,19 @@ export const hu: Dict = { 'diagnostics.exporting': 'Exportálás…', 'diagnostics.exportSuccess': 'Diagnosztika mentve: {path}', '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 2–5 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 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 0a274bd97..0704359a4 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -1841,4 +1841,19 @@ export const id: Dict = { 'diagnostics.exporting': 'Mengekspor…', 'diagnostics.exportSuccess': 'Diagnostik disimpan di {path}', 'diagnostics.exportFailed': 'Gagal mengekspor diagnostik: {message}', + 'generationPreview.title': 'Membuat…', + 'generationPreview.failedTitle': 'Pembuatan gagal', + 'generationPreview.failedFallback': 'Terjadi kesalahan. Silakan coba lagi.', + 'generationPreview.footnote': 'Biasanya butuh 2–5 menit', + 'generationPreview.stepUnderstand': 'Memahami kebutuhan', + 'generationPreview.stepGenerate': 'Membuat halaman', + 'generationPreview.stepPrepare': 'Menyiapkan pratinjau', + 'generationPreview.elapsed': '{elapsed} berlalu', + 'generationPreview.estimate': 'Biasanya 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 0d47bc335..ad94591f3 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -1667,4 +1667,19 @@ export const it: Dict = { 'liveArtifact.viewer.code.loading': 'Caricamento codice…', 'liveArtifact.viewer.code.unavailable': 'Il codice non è ancora disponibile.', '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 2–5 minuti', + 'generationPreview.stepUnderstand': 'Analisi dei requisiti', + 'generationPreview.stepGenerate': 'Generazione della pagina', + 'generationPreview.stepPrepare': 'Preparazione dell\'anteprima', + 'generationPreview.elapsed': '{elapsed} trascorsi', + 'generationPreview.estimate': 'Di solito 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 73ed56a1e..bd8f3f682 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -1735,4 +1735,19 @@ export const ja: 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': '再試行', + 'generationPreview.awaitingTitle': '入力をお待ちしています', + 'generationPreview.awaitingLead': 'チャットでいくつかの質問に答えると続行します。', + 'generationPreview.stoppedTitle': '生成を一時停止しました', + 'generationPreview.stoppedLead': '左側のチャットから残りのステップを再開できます。', }; diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index ae326dcfd..a4a2fcadc 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -1848,4 +1848,19 @@ export const ko: 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': '다시 시도', + 'generationPreview.awaitingTitle': '입력을 기다리는 중', + 'generationPreview.awaitingLead': '왼쪽 채팅에서 몇 가지 질문에 답하면 계속됩니다.', + 'generationPreview.stoppedTitle': '생성이 일시중지됨', + 'generationPreview.stoppedLead': '왼쪽 채팅에서 남은 단계를 다시 시작할 수 있습니다.', }; diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 94687b71f..dae26dd97 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -1798,4 +1798,19 @@ export const pl: Dict = { 'diagnostics.exporting': 'Eksportowanie…', 'diagnostics.exportSuccess': 'Diagnostyka zapisana w {path}', '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 2–5 minut', + 'generationPreview.stepUnderstand': 'Analiza wymagań', + 'generationPreview.stepGenerate': 'Generowanie strony', + 'generationPreview.stepPrepare': 'Przygotowanie podglądu', + 'generationPreview.elapsed': 'Upłynęło {elapsed}', + 'generationPreview.estimate': 'Zwykle 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 33ae69f71..b66087fa2 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -1839,4 +1839,19 @@ export const ptBR: Dict = { 'diagnostics.exporting': 'Exportando…', 'diagnostics.exportSuccess': 'Diagnósticos salvos em {path}', '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 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 641fe26d9..5ca28ff25 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -1839,4 +1839,19 @@ export const ru: 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': 'Повторить', + 'generationPreview.awaitingTitle': 'Ожидание вашего ответа', + 'generationPreview.awaitingLead': 'Ответьте на несколько вопросов в чате, чтобы продолжить.', + 'generationPreview.stoppedTitle': 'Генерация приостановлена', + 'generationPreview.stoppedLead': 'Продолжите оставшиеся шаги в чате слева.', }; diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index de9c60601..306dd5351 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -1628,4 +1628,19 @@ export const th: Dict = { 'settings.designSystemsCategory': 'หมวดหมู่', 'settings.designSystemsAllCategories': 'ทุกหมวดหมู่', 'settings.designSystemsShowInHomeGallery': 'แสดงในแกลเลอรีหน้าแรก', + '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': 'ดำเนินการขั้นตอนที่เหลือต่อจากแชทด้านซ้าย', }; diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 165f2a0bc..68d94a7fd 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -1785,4 +1785,19 @@ export const tr: Dict = { 'diagnostics.exporting': 'Dışa aktarılıyor…', 'diagnostics.exportSuccess': 'Tanılama {path} konumuna kaydedildi', '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 2–5 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 2–5 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.', }; diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 4f547d272..1189a6b68 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -1841,4 +1841,19 @@ export const uk: 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': 'Повторити', + 'generationPreview.awaitingTitle': 'Очікування вашої відповіді', + 'generationPreview.awaitingLead': 'Дайте відповідь на кілька запитань у чаті, щоб продовжити.', + 'generationPreview.stoppedTitle': 'Генерацію призупинено', + 'generationPreview.stoppedLead': 'Продовжте решту кроків у чаті ліворуч.', }; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 498acce7d..1d6688c0e 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -2550,4 +2550,19 @@ 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': '重试', + 'generationPreview.awaitingTitle': '等待你的补充', + 'generationPreview.awaitingLead': '在左侧聊天里回答几个问题即可继续生成。', + 'generationPreview.stoppedTitle': '生成已暂停', + 'generationPreview.stoppedLead': '可在左侧聊天里继续未完成的步骤。', }; diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 32ede235d..3017e781d 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -2118,4 +2118,19 @@ export const zhTW: Dict = { 'skillPluginCandidate.publishRepo': '發布倉庫', 'skillPluginCandidate.dismiss': '忽略', 'skillPluginCandidate.repoDescription': '這個倉庫看起來可以做成外掛。', + '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': '可在左側聊天裡繼續未完成的步驟。', }; diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 487f14e8d..2840543a7 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1897,6 +1897,21 @@ 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; + 'generationPreview.awaitingTitle': string; + 'generationPreview.awaitingLead': string; + 'generationPreview.stoppedTitle': string; + 'generationPreview.stoppedLead': string; 'designFiles.title': string; 'designFiles.upload': string; 'designFiles.pasteText': string; diff --git a/apps/web/src/runtime/generation-preview.ts b/apps/web/src/runtime/generation-preview.ts new file mode 100644 index 000000000..038bdc6a7 --- /dev/null +++ b/apps/web/src/runtime/generation-preview.ts @@ -0,0 +1,335 @@ +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 { + 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(' 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; + 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; +} diff --git a/apps/web/tests/runtime/generation-preview.test.ts b/apps/web/tests/runtime/generation-preview.test.ts new file mode 100644 index 000000000..a9b85e2f3 --- /dev/null +++ b/apps/web/tests/runtime/generation-preview.test.ts @@ -0,0 +1,311 @@ +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: 'hi', + }), + ).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{"questions":[]}', + runStatus: 'succeeded', + startedAt: Date.now() - 4_000, + events: [{ kind: 'text', text: '{"questions":[]}' }], + }; + 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(); + }); + +});