From 167db9de2060c8cb7de8d65c8f750f19954eaddd Mon Sep 17 00:00:00 2001 From: "open-design-crew[bot]" <299007234+open-design-crew[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:47:02 +0000 Subject: [PATCH] feat(web): add preview delivery status feedback (#5557) * feat(web): add preview delivery status feedback * fix(web): keep delivery retry in chat * feat(web): stage preview delivery progress * refine(web): clarify preview delivery stages * fix(web): preserve preview delivery feedback * fix(web): address preview delivery review * refine(web): keep preview delivery status passive * Revert "refine(web): keep preview delivery status passive" This reverts commit df5579da48b46c7bde1b198fcf397ee5460459da. --------- Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com> --- apps/web/src/analytics/events.ts | 16 ++ apps/web/src/components/AssistantMessage.tsx | 7 +- apps/web/src/components/DesignFilesPanel.tsx | 12 +- apps/web/src/components/FileWorkspace.tsx | 21 +- .../components/PreviewRunStatusBar.module.css | 97 +++++++++ .../src/components/PreviewRunStatusBar.tsx | 193 ++++++++++++++++++ apps/web/src/components/ProjectView.tsx | 20 +- apps/web/src/i18n/locales/ar.ts | 7 + apps/web/src/i18n/locales/de.ts | 7 + apps/web/src/i18n/locales/en.ts | 7 + apps/web/src/i18n/locales/es-ES.ts | 7 + apps/web/src/i18n/locales/fa.ts | 7 + apps/web/src/i18n/locales/fr.ts | 7 + apps/web/src/i18n/locales/hu.ts | 7 + apps/web/src/i18n/locales/id.ts | 7 + apps/web/src/i18n/locales/it.ts | 7 + apps/web/src/i18n/locales/ja.ts | 7 + apps/web/src/i18n/locales/ko.ts | 7 + apps/web/src/i18n/locales/pl.ts | 7 + apps/web/src/i18n/locales/pt-BR.ts | 7 + apps/web/src/i18n/locales/ru.ts | 7 + apps/web/src/i18n/locales/th.ts | 7 + apps/web/src/i18n/locales/tr.ts | 7 + apps/web/src/i18n/locales/uk.ts | 7 + apps/web/src/i18n/locales/zh-CN.ts | 7 + apps/web/src/i18n/locales/zh-TW.ts | 7 + apps/web/src/i18n/types.ts | 7 + apps/web/src/runtime/preview-run-status.ts | 139 +++++++++++++ .../web/src/styles/workspace/design-files.css | 13 ++ apps/web/src/styles/workspace/drawer.css | 20 ++ .../tests/components/FileWorkspace.test.tsx | 106 ++++++++++ .../components/PreviewRunStatusBar.test.tsx | 69 +++++++ .../ProjectView.run-isolation.test.tsx | 69 +++---- .../tests/runtime/preview-run-status.test.ts | 151 ++++++++++++++ e2e/ui/preview-run-status.test.ts | 124 +++++++++++ .../src/analytics/events/surface-view.ts | 17 ++ .../src/analytics/events/ui-click.ts | 14 +- 37 files changed, 1182 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/components/PreviewRunStatusBar.module.css create mode 100644 apps/web/src/components/PreviewRunStatusBar.tsx create mode 100644 apps/web/src/runtime/preview-run-status.ts create mode 100644 apps/web/tests/components/PreviewRunStatusBar.test.tsx create mode 100644 apps/web/tests/runtime/preview-run-status.test.ts create mode 100644 e2e/ui/preview-run-status.test.ts diff --git a/apps/web/src/analytics/events.ts b/apps/web/src/analytics/events.ts index 20f189b722..804de2923d 100644 --- a/apps/web/src/analytics/events.ts +++ b/apps/web/src/analytics/events.ts @@ -18,6 +18,7 @@ import type { DesignSystemsTemplatesModalSurfaceViewProps, AssistantFeedbackReasonPanelSurfaceViewProps, QuestionsFormSurfaceViewProps, + PreviewRunStatusSurfaceViewProps, // ui_click HomeNavClickProps, HelpPopoverClickProps, @@ -70,6 +71,7 @@ import type { NextStepActionClickProps, QuestionsFormClickProps, RunFailedToastClickProps, + PreviewRunStatusClickProps, AmrAuthResultProps, AmrEntryClickProps, RunFailedToastSurfaceViewProps, @@ -256,6 +258,13 @@ export function trackRunFailedToastSurfaceView( send(track, 'surface_view', props); } +export function trackPreviewRunStatusSurfaceView( + track: Track, + props: PreviewRunStatusSurfaceViewProps, +): void { + send(track, 'surface_view', props); +} + export function trackQuestionsFormSurfaceView( track: Track, props: QuestionsFormSurfaceViewProps, @@ -270,6 +279,13 @@ export function trackRunFailedToastGoAmrClick( send(track, 'ui_click', props); } +export function trackPreviewRunStatusClick( + track: Track, + props: PreviewRunStatusClickProps, +): void { + send(track, 'ui_click', props); +} + // Personalized first-run recommendation on Home (spec §7). export function trackHomeRecommendationSurfaceView( track: Track, diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 69bf6c329e..f4818950ea 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -817,7 +817,12 @@ function AssistantMessageImpl({ } return ( -
+
{roleName} diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index be51e77832..bb6fa683c4 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -206,9 +206,11 @@ function prefersReducedMotion(): boolean { } // Footer "tip" line that types out one tip at a time (typewriter), holds, then -// advances to the next — mirroring Claude Design's empty-state hint. Under -// prefers-reduced-motion the full tip is shown immediately and just cycles. -function RotatingTip() { +// advances to the next — mirroring Claude Design's empty-state hint. It is +// intentionally auxiliary while a run is active; the preview status bar owns +// progress and recovery feedback. Under prefers-reduced-motion the full tip is +// shown immediately and just cycles. +function RotatingTip({ auxiliary = false }: { auxiliary?: boolean }) { const t = useT(); const [index, setIndex] = useState(0); const [typed, setTyped] = useState(''); @@ -257,7 +259,7 @@ function RotatingTip() { }, [index]); return ( -
+
{t('designFiles.usefulInfoLabel')} @@ -1282,7 +1284,7 @@ export function DesignFilesPanel({ )}
{running ? ( - + ) : (
diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 76c0f44981..40922e73af 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -142,6 +142,7 @@ import { LiveArtifactBadges } from './LiveArtifactBadges'; import { MissingBrandFontsBanner } from './MissingBrandFontsBanner'; import { LibraryPicker } from './LibraryPicker'; import { QuestionsPanel } from './QuestionsPanel'; +import { PreviewRunStatusBar } from './PreviewRunStatusBar'; import { QuickSwitcher } from './QuickSwitcher'; import { SketchEditor } from './SketchEditor'; import { SketchEnginePrewarm } from './SketchEnginePrewarm'; @@ -272,7 +273,8 @@ interface Props { messages?: ChatMessage[]; artifactHtml?: string | null; conversationError?: string | null; - onRetry?: (message: ChatMessage) => void; + /** Restores the chat pane when the compact preview hint requests details. */ + onViewRunDetails?: (message: ChatMessage) => void; // Contextual failure recovery, mirrored from the chat error card so the // preview surface can offer the same one-click fix (AMR authorize, terminal // sign-in) instead of a bare retry. @@ -1281,6 +1283,7 @@ export function FileWorkspace({ onActiveContextChange, onWorkspaceContextsChange, messages = [], + onViewRunDetails, conversationId, headerActions, questionForm = null, @@ -2930,6 +2933,12 @@ export function FileWorkspace({ return liveArtifactEntries.find((entry) => entry.tabId === activeTab) ?? null; }, [activeTab, liveArtifactEntries]); + // The delivery hint belongs to the main design-preview surface only. Browser, + // terminal, questions, design-system, and side-chat tabs carry their own + // context and must not inherit status/analytics from the primary chat. + const showPreviewRunStatus = + activeTab === DESIGN_FILES_TAB || activeLiveArtifact !== null || activeFile !== null; + // Identity-stable props for the memoized FileViewer. Without these, every // FileWorkspace state change (closing an adjacent tab, drag hover, launcher // toggles) would hand FileViewer fresh object/function identities and drag @@ -4012,6 +4021,16 @@ export function FileWorkspace({ .
)} + {showPreviewRunStatus ? ( +
+ +
+ ) : null}
void; +} + +function statusLabelKey(status: PreviewRunStatus): + | 'previewRunStatus.analyzing' + | 'previewRunStatus.generating' + | 'previewRunStatus.verifying' + | 'previewRunStatus.succeeded' + | 'previewRunStatus.failed' { + switch (status.phase) { + case 'generating': + return status.stage === 'analyzing' + ? 'previewRunStatus.analyzing' + : 'previewRunStatus.generating'; + case 'verifying': + return 'previewRunStatus.verifying'; + case 'succeeded': + return 'previewRunStatus.succeeded'; + case 'failed': + return 'previewRunStatus.failed'; + } +} + +/** Lightweight run feedback embedded directly in the preview canvas. */ +export function PreviewRunStatusBar({ + projectId, + conversationId, + messages, + onViewDetails, +}: Props) { + const { t } = useI18n(); + const analytics = useAnalytics(); + const [now, setNow] = useState(() => Date.now()); + const current = useMemo( + () => { + // `now` is only a render tick for active/success timers. Evaluate the + // message set against the wall clock so switching to an old conversation + // cannot briefly revive an already-expired success state. + const evaluatedAt = Date.now(); + const status = latestPreviewRunStatus(messages, evaluatedAt); + return status && previewRunStatusVisibleAt(status, evaluatedAt) ? status : null; + }, + [conversationId, messages, now], + ); + const [lastVisible, setLastVisible] = useState(current); + const [leaving, setLeaving] = useState(false); + const exposureRef = useRef(null); + + const currentKey = current + ? `${current.message.id}:${current.phase}:${current.message.resultDeliveryState ?? ''}` + : null; + + useEffect(() => { + if (!current) { + if (lastVisible?.phase === 'succeeded') { + setLeaving(true); + const clear = window.setTimeout(() => setLastVisible(null), SUCCESS_EXIT_MS); + return () => window.clearTimeout(clear); + } + setLastVisible(null); + return; + } + setLastVisible(current); + setLeaving(false); + }, [currentKey, current, lastVisible?.phase]); + + useEffect(() => { + if (!current) return; + if (current.phase === 'generating' || current.phase === 'verifying') { + const interval = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(interval); + } + if (current.phase !== 'succeeded') return; + + const completedAt = previewRunStatusCompletedAt(current); + if (completedAt === undefined) return; + const remaining = Math.max(0, completedAt + PREVIEW_RUN_SUCCESS_VISIBLE_MS - Date.now()); + const fade = window.setTimeout( + () => setLeaving(true), + Math.max(0, remaining - SUCCESS_EXIT_MS), + ); + const expire = window.setTimeout(() => setNow(Date.now()), remaining); + return () => { + window.clearTimeout(fade); + window.clearTimeout(expire); + }; + }, [currentKey, current]); + + useEffect(() => { + if (!current || leaving || exposureRef.current === currentKey) return; + exposureRef.current = currentKey; + trackPreviewRunStatusSurfaceView(analytics.track, { + page_name: 'file_manager', + area: 'preview_run_status', + element: 'run_status_bar', + status: current.phase, + ...(current.message.resultDeliveryState + ? { delivery_state: current.message.resultDeliveryState } + : {}), + project_id: projectId, + conversation_id: conversationId ?? null, + assistant_message_id: current.message.id, + ...(current.message.runId ? { run_id: current.message.runId } : {}), + }); + }, [analytics.track, conversationId, current, currentKey, leaving, projectId]); + + const displayed = current ?? lastVisible; + if (!displayed) return null; + + const elapsed = formatPreviewRunElapsed(displayed.elapsedMs); + const isFailure = displayed.phase === 'failed'; + const label = t(statusLabelKey(displayed)); + const trackClick = () => { + trackPreviewRunStatusClick(analytics.track, { + page_name: 'file_manager', + area: 'preview_run_status', + element: 'view_details', + status: displayed.phase, + ...(displayed.message.resultDeliveryState + ? { delivery_state: displayed.message.resultDeliveryState } + : {}), + project_id: projectId, + conversation_id: conversationId ?? null, + assistant_message_id: displayed.message.id, + ...(displayed.message.runId ? { run_id: displayed.message.runId } : {}), + }); + }; + + return ( +
+
+ + {label} + + {isFailure ? null : ( + + )} + {isFailure ? ( + onViewDetails ? ( + + ) : null + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index b3d8aa4dfb..5e7705e5c8 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -1544,6 +1544,24 @@ export function ProjectView({ const [commentInspectorActive, setCommentInspectorActive] = useState(false); const commentInspectorPortalId = useId(); const leftInspectorActive = commentInspectorActive; + const handleViewRunDetails = useCallback((message: ChatMessage) => { + // Restore the primary chat even if a comment inspector or focus mode was + // active, then move directly to the failure that owns this hint. + setCommentInspectorActive(false); + setWorkspaceFocused(false); + + const focusFailure = () => { + const target = document.getElementById(`assistant-message-${message.id}`); + if (!target) return; + target.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + target.focus({ preventScroll: true }); + }; + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(focusFailure); + } else { + window.setTimeout(focusFailure, 0); + } + }, []); // Per-session override for the BYOK chat's generate_image tool. Seeded once // from the New Project → Media model pick (project.metadata.imageModel) — but // only when that pick belongs to the active BYOK provider (see @@ -8934,7 +8952,7 @@ export function ProjectView({ messages={messages} artifactHtml={artifact?.html} conversationError={error} - onRetry={handleRetry} + onViewRunDetails={handleViewRunDetails} onAuthorizeAndRetry={handleSwitchToAmrAndRetry} onLaunchTerminalAuth={handleLaunchAntigravityOauth} conversationId={activeConversationId} diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 721a36e964..7863e9fb9e 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -2423,6 +2423,13 @@ export const ar: Dict = { 'workspace.deleteSelectedFilesConfirm': 'حذف {n} ملف(ات) محددة من مجلد المشروع؟', 'workspace.deleteSelectedFilesPartial': 'فشل حذف {n} ملف(ات).', 'workspace.openFromDesignFiles': 'فتح ملف من', + 'previewRunStatus.analyzing': 'جارٍ تنظيم بنية المعلومات', + 'previewRunStatus.generating': 'جارٍ بناء الحل التصميمي', + 'previewRunStatus.verifying': 'جارٍ التحقق من مخرجات التسليم', + 'previewRunStatus.succeeded': 'التصميم جاهز', + 'previewRunStatus.failed': 'التسليم يحتاج إلى اهتمام', + 'previewRunStatus.elapsed': 'الوقت المنقضي {time}', + 'previewRunStatus.viewDetails': 'عرض التفاصيل', 'workspace.designFilesLink': 'ملفات التصميم', 'workspace.loadingSketch': 'جاري تحميل الرسم...', 'workspace.newTab': 'علامة تبويب جديدة', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index a61e65d5d8..265c973202 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -2423,6 +2423,13 @@ export const de: Dict = { 'workspace.deleteSelectedFilesConfirm': '{n} ausgewählte Datei(en) aus dem Projektordner löschen?', 'workspace.deleteSelectedFilesPartial': '{n} Datei(en) konnten nicht gelöscht werden.', 'workspace.openFromDesignFiles': 'Datei öffnen aus', + 'previewRunStatus.analyzing': 'Informationsarchitektur wird strukturiert', + 'previewRunStatus.generating': 'Designkonzept wird ausgearbeitet', + 'previewRunStatus.verifying': 'Übergabeergebnis wird geprüft', + 'previewRunStatus.succeeded': 'Design ist bereit', + 'previewRunStatus.failed': 'Auslieferung erfordert Aufmerksamkeit', + 'previewRunStatus.elapsed': 'Verstrichen {time}', + 'previewRunStatus.viewDetails': 'Details ansehen', 'workspace.designFilesLink': 'Design-Dateien', 'workspace.loadingSketch': 'Sketch wird geladen…', 'workspace.newTab': 'Neuer Tab', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 73078f85a3..c13217645b 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -2437,6 +2437,13 @@ export const en: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Delete {n} selected file(s) from the project folder?', 'workspace.deleteSelectedFilesPartial': 'Failed to delete {n} file(s).', 'workspace.openFromDesignFiles': 'Open a file from', + 'previewRunStatus.analyzing': 'Structuring information architecture', + 'previewRunStatus.generating': 'Building design solution', + 'previewRunStatus.verifying': 'Validating delivery', + 'previewRunStatus.succeeded': 'Design ready', + 'previewRunStatus.failed': 'Delivery needs attention', + 'previewRunStatus.elapsed': 'Elapsed {time}', + 'previewRunStatus.viewDetails': 'View details', 'workspace.designFilesLink': 'Design Files', 'workspace.loadingSketch': 'Loading sketch…', 'workspace.newTab': 'New tab', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 087596f916..804a1e16b3 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -2423,6 +2423,13 @@ export const esES: Dict = { 'workspace.deleteSelectedFilesConfirm': '¿Eliminar {n} archivo(s) seleccionado(s) de la carpeta del proyecto?', 'workspace.deleteSelectedFilesPartial': 'No se pudieron eliminar {n} archivo(s).', 'workspace.openFromDesignFiles': 'Abre un archivo desde', + 'previewRunStatus.analyzing': 'Estructurando la arquitectura de la información', + 'previewRunStatus.generating': 'Desarrollando la solución de diseño', + 'previewRunStatus.verifying': 'Validando la entrega', + 'previewRunStatus.succeeded': 'Diseño listo', + 'previewRunStatus.failed': 'La entrega necesita atención', + 'previewRunStatus.elapsed': 'Transcurrido {time}', + 'previewRunStatus.viewDetails': 'Ver detalles', 'workspace.designFilesLink': 'Archivos de diseño', 'workspace.loadingSketch': 'Cargando boceto…', 'workspace.newTab': 'Nueva pestaña', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 0ab29dbd3e..86eacd270f 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -2423,6 +2423,13 @@ export const fa: Dict = { 'workspace.deleteSelectedFilesConfirm': 'آیا {n} فایل انتخاب‌شده از پوشه پروژه حذف شوند؟', 'workspace.deleteSelectedFilesPartial': 'حذف {n} فایل ناموفق بود.', 'workspace.openFromDesignFiles': 'باز کردن یک فایل از', + 'previewRunStatus.analyzing': 'در حال سامان‌دهی معماری اطلاعات', + 'previewRunStatus.generating': 'در حال ساخت راهکار طراحی', + 'previewRunStatus.verifying': 'در حال اعتبارسنجی تحویل', + 'previewRunStatus.succeeded': 'طراحی آماده است', + 'previewRunStatus.failed': 'تحویل نیاز به رسیدگی دارد', + 'previewRunStatus.elapsed': 'زمان سپری‌شده {time}', + 'previewRunStatus.viewDetails': 'مشاهده جزئیات', 'workspace.designFilesLink': 'فایل‌های طراحی', 'workspace.loadingSketch': 'در حال بارگذاری طرح…', 'workspace.newTab': 'زبانه جدید', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 01445473aa..72fb527042 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -2423,6 +2423,13 @@ export const fr: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Supprimer les {n} fichier(s) sélectionné(s) du dossier du projet ?', 'workspace.deleteSelectedFilesPartial': 'Échec de la suppression de {n} fichier(s).', 'workspace.openFromDesignFiles': 'Ouvrir un fichier depuis', + 'previewRunStatus.analyzing': 'Structuration de l’architecture de l’information', + 'previewRunStatus.generating': 'Conception de la solution de design', + 'previewRunStatus.verifying': 'Validation de la livraison', + 'previewRunStatus.succeeded': 'Design prêt', + 'previewRunStatus.failed': 'La livraison nécessite votre attention', + 'previewRunStatus.elapsed': 'Temps écoulé {time}', + 'previewRunStatus.viewDetails': 'Voir les détails', 'workspace.designFilesLink': 'Fichiers de design', 'workspace.loadingSketch': 'Chargement du croquis…', 'workspace.newTab': 'Nouvel onglet', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index 024d72f5e7..4c66ae27aa 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -2423,6 +2423,13 @@ export const hu: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Törlöd a(z) {n} kijelölt fájlt a projektmappából?', 'workspace.deleteSelectedFilesPartial': '{n} fájl törlése sikertelen.', 'workspace.openFromDesignFiles': 'Nyiss meg egy fájlt innen:', + 'previewRunStatus.analyzing': 'Információs architektúra kialakítása', + 'previewRunStatus.generating': 'Tervezési megoldás kidolgozása', + 'previewRunStatus.verifying': 'Az átadás ellenőrzése', + 'previewRunStatus.succeeded': 'A terv elkészült', + 'previewRunStatus.failed': 'A kézbesítés figyelmet igényel', + 'previewRunStatus.elapsed': 'Eltelt idő {time}', + 'previewRunStatus.viewDetails': 'Részletek', 'workspace.designFilesLink': 'Designfájlok', 'workspace.loadingSketch': 'Vázlat betöltése…', 'workspace.newTab': 'Új lap', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 84cecc8c7e..59c3dd8cee 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -2423,6 +2423,13 @@ export const id: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Hapus {n} file terpilih dari folder proyek?', 'workspace.deleteSelectedFilesPartial': 'Gagal menghapus {n} file.', 'workspace.openFromDesignFiles': 'Buka file dari', + 'previewRunStatus.analyzing': 'Menyusun arsitektur informasi', + 'previewRunStatus.generating': 'Membangun solusi desain', + 'previewRunStatus.verifying': 'Memvalidasi penyerahan', + 'previewRunStatus.succeeded': 'Desain siap', + 'previewRunStatus.failed': 'Pengiriman perlu diperhatikan', + 'previewRunStatus.elapsed': 'Berlalu {time}', + 'previewRunStatus.viewDetails': 'Lihat detail', 'workspace.designFilesLink': 'File desain', 'workspace.loadingSketch': 'Memuat sketsa...', 'workspace.newTab': 'New tab', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index b306f49929..35e8af8a4c 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -2423,6 +2423,13 @@ export const it: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Eliminare i {n} file selezionati dalla cartella del progetto?', 'workspace.deleteSelectedFilesPartial': 'Impossibile eliminare {n} file.', 'workspace.openFromDesignFiles': 'Apri un file da', + 'previewRunStatus.analyzing': 'Strutturazione dell’architettura informativa', + 'previewRunStatus.generating': 'Sviluppo della soluzione di design', + 'previewRunStatus.verifying': 'Verifica della consegna', + 'previewRunStatus.succeeded': 'Design pronto', + 'previewRunStatus.failed': 'La consegna richiede attenzione', + 'previewRunStatus.elapsed': 'Trascorso {time}', + 'previewRunStatus.viewDetails': 'Visualizza dettagli', 'workspace.designFilesLink': 'File di design', 'workspace.loadingSketch': 'Caricamento dello schizzo…', 'workspace.newTab': 'Nuova scheda', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 65eb409210..42b7125b05 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -2423,6 +2423,13 @@ export const ja: Dict = { 'workspace.deleteSelectedFilesConfirm': 'プロジェクトフォルダーから選択した {n} 個のファイルを削除しますか?', 'workspace.deleteSelectedFilesPartial': '{n} 個のファイルの削除に失敗しました。', 'workspace.openFromDesignFiles': 'ファイルを開く: ', + 'previewRunStatus.analyzing': '情報アーキテクチャを整理中', + 'previewRunStatus.generating': 'デザイン案を構築中', + 'previewRunStatus.verifying': '納品内容を検証中', + 'previewRunStatus.succeeded': 'デザインの準備ができました', + 'previewRunStatus.failed': '成果物の受け渡しに対応が必要です', + 'previewRunStatus.elapsed': '経過時間 {time}', + 'previewRunStatus.viewDetails': '詳細を見る', 'workspace.designFilesLink': 'デザインファイル', 'workspace.loadingSketch': 'スケッチを読み込み中…', 'workspace.newTab': '新しいタブ', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 905e7a2ef9..4ddb58cd34 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -2423,6 +2423,13 @@ export const ko: Dict = { 'workspace.deleteSelectedFilesConfirm': '프로젝트 폴더에서 선택한 {n}개 파일을 삭제하시겠습니까?', 'workspace.deleteSelectedFilesPartial': '{n}개 파일을 삭제하지 못했습니다.', 'workspace.openFromDesignFiles': '디자인 파일 열기', + 'previewRunStatus.analyzing': '정보 구조를 설계하는 중', + 'previewRunStatus.generating': '디자인 솔루션을 구축하는 중', + 'previewRunStatus.verifying': '전달 결과를 검증하는 중', + 'previewRunStatus.succeeded': '디자인이 준비되었습니다', + 'previewRunStatus.failed': '전달에 확인이 필요합니다', + 'previewRunStatus.elapsed': '경과 시간 {time}', + 'previewRunStatus.viewDetails': '세부정보 보기', 'workspace.designFilesLink': '디자인 파일', 'workspace.loadingSketch': '스케치 불러오는 중…', 'workspace.newTab': '새 탭', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index a1e1865ccd..5b707f0b29 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -2423,6 +2423,13 @@ export const pl: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Usunąć {n} wybranych plików z folderu projektu?', 'workspace.deleteSelectedFilesPartial': 'Nie udało się usunąć {n} plików.', 'workspace.openFromDesignFiles': 'Otwórz plik z', + 'previewRunStatus.analyzing': 'Porządkowanie architektury informacji', + 'previewRunStatus.generating': 'Tworzenie rozwiązania projektowego', + 'previewRunStatus.verifying': 'Weryfikowanie dostarczenia', + 'previewRunStatus.succeeded': 'Projekt jest gotowy', + 'previewRunStatus.failed': 'Dostarczenie wymaga uwagi', + 'previewRunStatus.elapsed': 'Czas {time}', + 'previewRunStatus.viewDetails': 'Zobacz szczegóły', 'workspace.designFilesLink': 'Pliki projektu', 'workspace.loadingSketch': 'Ładowanie szkicu…', 'workspace.newTab': 'Nowa karta', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index e6bc5621df..d8ddf04fd3 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -2423,6 +2423,13 @@ export const ptBR: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Excluir {n} arquivo(s) selecionado(s) da pasta do projeto?', 'workspace.deleteSelectedFilesPartial': 'Falha ao excluir {n} arquivo(s).', 'workspace.openFromDesignFiles': 'Abra um arquivo em', + 'previewRunStatus.analyzing': 'Estruturando a arquitetura da informação', + 'previewRunStatus.generating': 'Construindo a solução de design', + 'previewRunStatus.verifying': 'Validando a entrega', + 'previewRunStatus.succeeded': 'Design pronto', + 'previewRunStatus.failed': 'A entrega precisa de atenção', + 'previewRunStatus.elapsed': 'Decorrido {time}', + 'previewRunStatus.viewDetails': 'Ver detalhes', 'workspace.designFilesLink': 'Arquivos de design', 'workspace.loadingSketch': 'Carregando esboço…', 'workspace.newTab': 'Nova aba', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 3269308eca..45a05b2c7a 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -2423,6 +2423,13 @@ export const ru: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Удалить {n} выбранных файла(ов) из папки проекта?', 'workspace.deleteSelectedFilesPartial': 'Не удалось удалить {n} файл(ов).', 'workspace.openFromDesignFiles': 'Открыть файл из', + 'previewRunStatus.analyzing': 'Формируем информационную архитектуру', + 'previewRunStatus.generating': 'Создаём дизайн-решение', + 'previewRunStatus.verifying': 'Проверяем результат передачи', + 'previewRunStatus.succeeded': 'Дизайн готов', + 'previewRunStatus.failed': 'Доставка требует внимания', + 'previewRunStatus.elapsed': 'Прошло {time}', + 'previewRunStatus.viewDetails': 'Подробнее', 'workspace.designFilesLink': 'Файлы дизайна', 'workspace.loadingSketch': 'Загрузка эскиза…', 'workspace.newTab': 'Новая вкладка', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 9ccc20cfaa..df693a266b 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -2423,6 +2423,13 @@ export const th: Dict = { 'workspace.deleteSelectedFilesConfirm': 'ลบไฟล์ {n} รายการที่เลือกออกจากโปรเจกต์?', 'workspace.deleteSelectedFilesPartial': 'ลบ {n} รายการไม่ผ่าน', 'workspace.openFromDesignFiles': 'เปิดไฟล์จาก', + 'previewRunStatus.analyzing': 'กำลังจัดโครงสร้างสารสนเทศ', + 'previewRunStatus.generating': 'กำลังสร้างโซลูชันการออกแบบ', + 'previewRunStatus.verifying': 'กำลังตรวจสอบการส่งมอบ', + 'previewRunStatus.succeeded': 'ดีไซน์พร้อมแล้ว', + 'previewRunStatus.failed': 'การส่งมอบต้องได้รับการตรวจสอบ', + 'previewRunStatus.elapsed': 'เวลาที่ใช้ {time}', + 'previewRunStatus.viewDetails': 'ดูรายละเอียด', 'workspace.designFilesLink': 'หน้าไฟล์งาน', 'workspace.loadingSketch': 'กำลังโหลดกระดานวาด…', 'workspace.newTab': 'แท็บใหม่', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 6b30d3efa3..19118981cf 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -2423,6 +2423,13 @@ export const tr: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Seçili {n} dosya proje klasöründen silinsin mi?', 'workspace.deleteSelectedFilesPartial': '{n} dosya silinemedi.', 'workspace.openFromDesignFiles': 'bir dosya aç', + 'previewRunStatus.analyzing': 'Bilgi mimarisi yapılandırılıyor', + 'previewRunStatus.generating': 'Tasarım çözümü oluşturuluyor', + 'previewRunStatus.verifying': 'Teslim sonucu doğrulanıyor', + 'previewRunStatus.succeeded': 'Tasarım hazır', + 'previewRunStatus.failed': 'Teslimatın ilgilenilmesi gerekiyor', + 'previewRunStatus.elapsed': 'Geçen süre {time}', + 'previewRunStatus.viewDetails': 'Ayrıntıları görüntüle', 'workspace.designFilesLink': 'Tasarım Dosyaları', 'workspace.loadingSketch': 'Taslak yükleniyor…', 'workspace.newTab': 'Yeni sekme', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 82aca852dc..eda9c8b208 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -2423,6 +2423,13 @@ export const uk: Dict = { 'workspace.deleteSelectedFilesConfirm': 'Видалити {n} вибраних файлів з папки проекту?', 'workspace.deleteSelectedFilesPartial': 'Не вдалося видалити {n} файл(ів).', 'workspace.openFromDesignFiles': 'Відкрити файл з', + 'previewRunStatus.analyzing': 'Формуємо інформаційну архітектуру', + 'previewRunStatus.generating': 'Створюємо дизайн-рішення', + 'previewRunStatus.verifying': 'Перевіряємо результат передавання', + 'previewRunStatus.succeeded': 'Дизайн готовий', + 'previewRunStatus.failed': 'Доставка потребує уваги', + 'previewRunStatus.elapsed': 'Минуло {time}', + 'previewRunStatus.viewDetails': 'Переглянути деталі', 'workspace.designFilesLink': 'Файли дизайну', 'workspace.loadingSketch': 'Завантаження ескізу…', 'workspace.newTab': 'Нова вкладка', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index d6b9947f11..d03caaa944 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -2639,6 +2639,13 @@ export const zhCN: Dict = { "从项目文件夹中删除选中的 {n} 个文件?", "workspace.deleteSelectedFilesPartial": "有 {n} 个文件删除失败。", "workspace.openFromDesignFiles": "请从", + "previewRunStatus.analyzing": "正在梳理信息架构", + "previewRunStatus.generating": "正在构建设计方案", + "previewRunStatus.verifying": "正在校验交付结果", + "previewRunStatus.succeeded": "设计已就绪", + "previewRunStatus.failed": "交付需要处理", + "previewRunStatus.elapsed": "已用时 {time}", + "previewRunStatus.viewDetails": "查看详情", "workspace.designFilesLink": "设计文件", "workspace.loadingSketch": "正在加载草图…", "workspace.newTab": "新建标签页", diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 07adb718a4..83795d5279 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -2649,6 +2649,13 @@ export const zhTW: Dict = { "從專案資料夾中刪除選中的 {n} 個檔案?", "workspace.deleteSelectedFilesPartial": "有 {n} 個檔案刪除失敗。", "workspace.openFromDesignFiles": "請從", + "previewRunStatus.analyzing": "正在梳理資訊架構", + "previewRunStatus.generating": "正在建構設計方案", + "previewRunStatus.verifying": "正在驗證交付結果", + "previewRunStatus.succeeded": "設計已就緒", + "previewRunStatus.failed": "交付需要處理", + "previewRunStatus.elapsed": "已用時 {time}", + "previewRunStatus.viewDetails": "查看詳情", "workspace.designFilesLink": "設計檔案", "workspace.loadingSketch": "正在載入草圖…", "workspace.newTab": "新增分頁", diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index c46e47cbd3..f8ace7b3f3 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -3224,6 +3224,13 @@ export interface Dict { 'workspace.terminalReconnecting': string; 'workspace.terminalStartFailed': string; 'workspace.terminalRestart': string; + 'previewRunStatus.analyzing': string; + 'previewRunStatus.generating': string; + 'previewRunStatus.verifying': string; + 'previewRunStatus.succeeded': string; + 'previewRunStatus.failed': string; + 'previewRunStatus.elapsed': string; + 'previewRunStatus.viewDetails': string; 'designFiles.title': string; 'designFiles.upload': string; 'designFiles.pasteText': string; diff --git a/apps/web/src/runtime/preview-run-status.ts b/apps/web/src/runtime/preview-run-status.ts new file mode 100644 index 0000000000..c5755ac851 --- /dev/null +++ b/apps/web/src/runtime/preview-run-status.ts @@ -0,0 +1,139 @@ +import type { ChatMessage } from '../types'; +import { + designDeliveryVerificationPending, + isRetryableAssistantTerminalFailure, +} from './design-delivery'; + +export const PREVIEW_RUN_SUCCESS_VISIBLE_MS = 5_000; + +export type PreviewRunStatusPhase = + | 'generating' + | 'verifying' + | 'succeeded' + | 'failed'; + +export type PreviewRunStatusStage = 'analyzing' | PreviewRunStatusPhase; + +export interface PreviewRunStatus { + message: ChatMessage; + phase: PreviewRunStatusPhase; + /** A real stream-derived label for the active generation phase. */ + stage: PreviewRunStatusStage; + elapsedMs: number; +} + +function isActiveDesignRun(message: ChatMessage): boolean { + return message.runStatus === 'queued' || message.runStatus === 'running'; +} + +// These statuses describe lifecycle progress, not a user-visible design +// output. Runtimes do not agree on the exact label, so keep looking back for +// an output-bearing event before advancing the preview copy to generation. +const PRE_OUTPUT_STATUS_LABELS = new Set([ + 'queued', + 'requesting', + 'starting', + 'initializing', + 'thinking', + 'running', + 'model', + 'working', + 'streaming', +]); + +function activeDesignRunStage(message: ChatMessage): Extract { + const events = message.events ?? []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (!event) continue; + if (event.kind === 'thinking') continue; + if (event.kind === 'status') { + if (PRE_OUTPUT_STATUS_LABELS.has(event.label)) continue; + return 'generating'; + } + if ( + event.kind === 'text' + || event.kind === 'tool_use' + || event.kind === 'tool_result' + || event.kind === 'live_artifact' + || event.kind === 'live_artifact_refresh' + ) { + return 'generating'; + } + } + + // A queued run has no agent event yet. Label it as analysis rather than + // pretending design output is already being produced. + return 'analyzing'; +} + +/** + * Finds the latest Design-mode turn that deserves an explicit status in the + * preview workspace. The source is the persisted conversation message, not + * component state, so an in-flight or failed run survives route changes. + */ +export function latestPreviewRunStatus( + messages: readonly ChatMessage[], + now: number, +): PreviewRunStatus | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message) continue; + if (message.role !== 'assistant' || message.sessionMode !== 'design') continue; + + let phase: PreviewRunStatusPhase | null = null; + let stage: PreviewRunStatusStage | null = null; + if (isActiveDesignRun(message)) { + phase = 'generating'; + stage = activeDesignRunStage(message); + } else if (designDeliveryVerificationPending(message)) { + phase = 'verifying'; + stage = 'verifying'; + } else if (isRetryableAssistantTerminalFailure(message)) { + phase = 'failed'; + stage = 'failed'; + } else if (message.runStatus === 'succeeded' && message.resultDeliveryState === 'delivered') { + phase = 'succeeded'; + stage = 'succeeded'; + } + + // A later Design turn supersedes any prior failure. For example, a + // clarification-only completion must not leave an earlier delivery error + // floating over the preview while the user supplies the requested input. + if (!phase || !stage) return null; + const start = message.startedAt ?? message.createdAt; + const end = isActiveDesignRun(message) ? now : (message.endedAt ?? now); + return { + message, + phase, + stage, + elapsedMs: start === undefined ? 0 : Math.max(0, end - start), + }; + } + return null; +} + +export function previewRunStatusVisibleAt( + status: PreviewRunStatus, + now: number, +): boolean { + if (status.phase !== 'succeeded') return true; + const completedAt = previewRunStatusCompletedAt(status); + return completedAt !== undefined && now < completedAt + PREVIEW_RUN_SUCCESS_VISIBLE_MS; +} + +/** + * Persisted turns normally carry `endedAt`. When an older record does not, + * anchor the confirmation to its real start/creation timestamp instead of + * reviving a stale success on every later visit. + */ +export function previewRunStatusCompletedAt(status: PreviewRunStatus): number | undefined { + return status.message.endedAt ?? status.message.startedAt ?? status.message.createdAt; +} + +export function formatPreviewRunElapsed(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} diff --git a/apps/web/src/styles/workspace/design-files.css b/apps/web/src/styles/workspace/design-files.css index 23e1753b4c..9e1b5cecde 100644 --- a/apps/web/src/styles/workspace/design-files.css +++ b/apps/web/src/styles/workspace/design-files.css @@ -812,6 +812,19 @@ color: var(--text); min-height: 1.5em; } +.df-useful-info-auxiliary { + opacity: 0.72; +} +.df-useful-info-auxiliary .df-useful-info-head { + color: var(--text-faint); +} +.df-useful-info-auxiliary .df-useful-info-label { + color: var(--text-faint); +} +.df-useful-info-auxiliary .df-useful-info-tip { + font-size: 12px; + color: var(--text-muted); +} .df-tip-link { color: inherit; text-decoration: underline; diff --git a/apps/web/src/styles/workspace/drawer.css b/apps/web/src/styles/workspace/drawer.css index e5d70c9685..c0b210d714 100644 --- a/apps/web/src/styles/workspace/drawer.css +++ b/apps/web/src/styles/workspace/drawer.css @@ -2544,6 +2544,26 @@ position: relative; } +/* Canvas-local Design delivery feedback. It stays with the workspace while + the empty placeholder hands over to an actual file preview, rather than + becoming a bottom-of-screen notification. */ +.ws-preview-run-status-slot { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + max-width: calc(100% - 48px); + pointer-events: none; + transform: translate(-50%, 116px); +} + +@media (max-width: 640px) { + .ws-preview-run-status-slot { + max-width: calc(100% - 32px); + transform: translate(-50%, 104px); + } +} + .ws-browser-panel { display: flex; position: absolute; diff --git a/apps/web/tests/components/FileWorkspace.test.tsx b/apps/web/tests/components/FileWorkspace.test.tsx index 311226b376..379bd3e879 100644 --- a/apps/web/tests/components/FileWorkspace.test.tsx +++ b/apps/web/tests/components/FileWorkspace.test.tsx @@ -3152,4 +3152,110 @@ describe('FileWorkspace empty-project generation contract', () => { expect(screen.getByTestId('design-files-empty')).toBeTruthy(); }, ); + + it('keeps delivery recovery in Chat and leaves a compact details handoff over existing preview files', () => { + const onViewRunDetails = vi.fn(); + render( + , + ); + + const previewStatus = screen.getByTestId('preview-run-status'); + expect(previewStatus).toHaveTextContent('Delivery needs attention'); + expect(previewStatus.closest('.ws-preview-run-status-slot')).not.toBeNull(); + expect(previewStatus.closest('[data-testid="design-files-empty"]')).toBeNull(); + expect(screen.queryByTestId('preview-run-status-retry')).toBeNull(); + expect(previewStatus).not.toHaveTextContent('Elapsed'); + fireEvent.click(screen.getByTestId('preview-run-status-view-details')); + expect(onViewRunDetails).toHaveBeenCalledWith( + expect.objectContaining({ id: 'delivery-failure' }), + ); + }); + + it('does not mount main-preview delivery feedback over a browser tab', () => { + render( + , + ); + + expect(screen.getByTestId('design-browser-panel')).toBeTruthy(); + expect(screen.queryByTestId('preview-run-status')).toBeNull(); + }); + + it('keeps a delivered confirmation on the preview canvas after files arrive', () => { + const now = 1_700_000_012_500; + vi.spyOn(Date, 'now').mockReturnValue(now); + render( + , + ); + + const previewStatus = screen.getByTestId('preview-run-status'); + expect(previewStatus).toHaveTextContent('Design ready'); + expect(previewStatus.closest('.ws-preview-run-status-slot')).not.toBeNull(); + expect(previewStatus.closest('[data-testid="design-files-empty"]')).toBeNull(); + expect(previewStatus).not.toHaveAttribute('aria-live'); + expect(within(previewStatus).getByRole('status')).toHaveTextContent('Design ready'); + expect(previewStatus.querySelector('[aria-hidden="true"]')).toHaveTextContent('Elapsed 0:03'); + }); }); diff --git a/apps/web/tests/components/PreviewRunStatusBar.test.tsx b/apps/web/tests/components/PreviewRunStatusBar.test.tsx new file mode 100644 index 0000000000..042cf82ee3 --- /dev/null +++ b/apps/web/tests/components/PreviewRunStatusBar.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { analyticsTrack } = vi.hoisted(() => ({ analyticsTrack: vi.fn() })); + +vi.mock('../../src/analytics/provider', () => ({ + useAnalytics: () => ({ track: analyticsTrack }), +})); + +import { PreviewRunStatusBar } from '../../src/components/PreviewRunStatusBar'; +import { I18nProvider } from '../../src/i18n'; +import type { ChatMessage } from '../../src/types'; + +const STARTED_AT = 1_700_000_000_000; + +function deliveredMessage(): ChatMessage { + return { + id: 'delivered-message', + role: 'assistant', + content: '', + sessionMode: 'design', + runStatus: 'succeeded', + resultDeliveryState: 'delivered', + createdAt: STARTED_AT, + startedAt: STARTED_AT, + endedAt: STARTED_AT, + }; +} + +function renderStatus(messages: ChatMessage[]) { + return render( + + + , + ); +} + +describe('PreviewRunStatusBar', () => { + afterEach(() => { + analyticsTrack.mockReset(); + vi.useRealTimers(); + }); + + it('does not flash or track an already-expired delivered turn after an idle rerender', () => { + vi.useFakeTimers(); + vi.setSystemTime(STARTED_AT); + const { rerender } = renderStatus([]); + + vi.advanceTimersByTime(6_000); + rerender( + + + , + ); + + expect(screen.queryByTestId('preview-run-status')).toBeNull(); + expect(analyticsTrack).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/components/ProjectView.run-isolation.test.tsx b/apps/web/tests/components/ProjectView.run-isolation.test.tsx index cee387b199..a6e4374ae5 100644 --- a/apps/web/tests/components/ProjectView.run-isolation.test.tsx +++ b/apps/web/tests/components/ProjectView.run-isolation.test.tsx @@ -124,7 +124,6 @@ vi.mock('../../src/components/FileWorkspace', () => ({ FileWorkspace: ({ streaming, messages, - onRetry, onAuthorizeAndRetry, onLaunchTerminalAuth, onSendBoardCommentAttachments, @@ -133,7 +132,6 @@ vi.mock('../../src/components/FileWorkspace', () => ({ }: { streaming: boolean; messages?: ChatMessage[]; - onRetry?: (message: ChatMessage) => void; onAuthorizeAndRetry?: (message: ChatMessage) => void; onLaunchTerminalAuth?: () => void; onSendBoardCommentAttachments: (attachments: unknown[]) => void; @@ -165,16 +163,6 @@ vi.mock('../../src/components/FileWorkspace', () => ({ failedAssistant?.agentId !== 'amr' && failedAssistant?.agentId !== 'antigravity' && (errorCode === 'AGENT_AUTH_REQUIRED' || errorCode === 'UNAUTHORIZED' || errorCode === 'RATE_LIMITED'); - const showRetryAction = Boolean( - failedAssistant && onRetry && ( - errorCode == null - || errorCode === 'AMR_INSUFFICIENT_BALANCE' - || errorCode === 'UPSTREAM_UNAVAILABLE' - || showLaunchTerminalAction - || showSwitchToAmrPromotion - || (!showAuthorizeAction && !showLaunchTerminalAction) - ), - ); return ( <> {streaming ? 'streaming' : 'idle'} @@ -199,17 +187,6 @@ vi.mock('../../src/components/FileWorkspace', () => ({ > workspace send - {showRetryAction ? ( - - ) : null} {showAuthorizeAction && onAuthorizeAndRetry ? ( + ) : null} {queuedItems?.map((item, index) => (