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 : (
+
+ {t('previewRunStatus.elapsed', { time: elapsed })}
+
+ )}
+ {isFailure ? (
+ onViewDetails ? (
+ {
+ trackClick();
+ onViewDetails(displayed.message);
+ }}
+ >
+ {t('previewRunStatus.viewDetails')}
+
+ ) : 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 ? (
- {
- if (failedAssistant && onRetry) onRetry(failedAssistant);
- }}
- >
- retry
-
- ) : null}
{showAuthorizeAction && onAuthorizeAndRetry ? (
({
onSendQueuedNow,
onNewConversation,
error,
+ onRetry,
}: {
activeConversationId: string | null;
conversations: Conversation[];
@@ -291,8 +269,20 @@ vi.mock('../../src/components/ChatPane', () => ({
) => void;
onSendQueuedNow?: (id: string) => void;
onNewConversation: () => void;
+ onRetry?: (message: ChatMessage) => void;
}) => {
const attached = attachedComments ?? [];
+ const retryTarget = [...(messages ?? [])]
+ .reverse()
+ .find(
+ (message) =>
+ message.role === 'assistant'
+ && (
+ message.runStatus === 'failed'
+ || message.resultDeliveryState === 'no_result'
+ || message.resultDeliveryState === 'delivery_failed'
+ ),
+ );
return (
{activeConversationId}
@@ -332,6 +322,11 @@ vi.mock('../../src/components/ChatPane', () => ({
.join('\n')}
{attached.length}
+ {retryTarget && onRetry ? (
+ onRetry(retryTarget)}>
+ retry
+
+ ) : null}
{queuedItems?.map((item, index) => (
{
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(2));
});
- it('keeps retry available after a structured AMR insufficient-balance error', async () => {
+ it('keeps Chat retry available after a structured AMR insufficient-balance error', async () => {
conversationAMessages = [];
fetchChatRunStatus.mockResolvedValue(null);
streamViaDaemon.mockImplementation(
@@ -1945,10 +1940,10 @@ describe('ProjectView conversation run isolation', () => {
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(screen.getByTestId('workspace-retry')).toBeTruthy());
+ await waitFor(() => expect(screen.getByTestId('chat-retry')).toBeTruthy());
expect(screen.getByTestId('streaming-state').textContent).toBe('idle');
- fireEvent.click(screen.getByTestId('workspace-retry'));
+ fireEvent.click(screen.getByTestId('chat-retry'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(2));
});
@@ -1984,9 +1979,9 @@ describe('ProjectView conversation run isolation', () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
- await waitFor(() => expect(screen.getByTestId('workspace-retry')).toBeTruthy());
+ await waitFor(() => expect(screen.getByTestId('chat-retry')).toBeTruthy());
- fireEvent.click(screen.getByTestId('workspace-retry'));
+ fireEvent.click(screen.getByTestId('chat-retry'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
const retryCall = streamViaDaemon.mock.calls[0]?.[0] as {
@@ -2037,9 +2032,9 @@ describe('ProjectView conversation run isolation', () => {
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
- await waitFor(() => expect(screen.getByTestId('workspace-retry')).toBeTruthy());
+ await waitFor(() => expect(screen.getByTestId('chat-retry')).toBeTruthy());
- fireEvent.click(screen.getByTestId('workspace-retry'));
+ fireEvent.click(screen.getByTestId('chat-retry'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
const retryCall = streamViaDaemon.mock.calls[0]?.[0] as {
@@ -2165,7 +2160,7 @@ describe('ProjectView conversation run isolation', () => {
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(2));
});
- it('routes workspace retry and terminal launch recovery for antigravity auth failures', async () => {
+ it('routes Chat retry and terminal launch recovery for antigravity auth failures', async () => {
conversationAMessages = [];
fetchChatRunStatus.mockResolvedValue(null);
streamViaDaemon.mockImplementation(
@@ -2207,16 +2202,16 @@ describe('ProjectView conversation run isolation', () => {
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByTestId('workspace-launch-terminal')).toBeTruthy());
- await waitFor(() => expect(screen.getByTestId('workspace-retry')).toBeTruthy());
+ await waitFor(() => expect(screen.getByTestId('chat-retry')).toBeTruthy());
fireEvent.click(screen.getByTestId('workspace-launch-terminal'));
await waitFor(() => expect(launchAntigravityOauth).toHaveBeenCalledTimes(1));
- fireEvent.click(screen.getByTestId('workspace-retry'));
+ fireEvent.click(screen.getByTestId('chat-retry'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(2));
});
- it('routes workspace retry and terminal launch recovery for antigravity rate limits', async () => {
+ it('routes Chat retry and terminal launch recovery for antigravity rate limits', async () => {
conversationAMessages = [];
fetchChatRunStatus.mockResolvedValue(null);
streamViaDaemon.mockImplementation(
@@ -2258,12 +2253,12 @@ describe('ProjectView conversation run isolation', () => {
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByTestId('workspace-launch-terminal')).toBeTruthy());
- await waitFor(() => expect(screen.getByTestId('workspace-retry')).toBeTruthy());
+ await waitFor(() => expect(screen.getByTestId('chat-retry')).toBeTruthy());
fireEvent.click(screen.getByTestId('workspace-launch-terminal'));
await waitFor(() => expect(launchAntigravityOauth).toHaveBeenCalledTimes(1));
- fireEvent.click(screen.getByTestId('workspace-retry'));
+ fireEvent.click(screen.getByTestId('chat-retry'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(2));
});
@@ -2308,7 +2303,7 @@ describe('ProjectView conversation run isolation', () => {
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(screen.getByTestId('workspace-retry')).toBeTruthy());
+ await waitFor(() => expect(screen.getByTestId('chat-retry')).toBeTruthy());
expect(screen.queryByTestId('workspace-switch-amr')).toBeNull();
expect(screen.queryByTestId('workspace-authorize')).toBeNull();
expect(screen.queryByTestId('workspace-launch-terminal')).toBeNull();
diff --git a/apps/web/tests/runtime/preview-run-status.test.ts b/apps/web/tests/runtime/preview-run-status.test.ts
new file mode 100644
index 0000000000..8bd5d77ea3
--- /dev/null
+++ b/apps/web/tests/runtime/preview-run-status.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ formatPreviewRunElapsed,
+ latestPreviewRunStatus,
+ PREVIEW_RUN_SUCCESS_VISIBLE_MS,
+ previewRunStatusVisibleAt,
+} from '../../src/runtime/preview-run-status';
+import type { ChatMessage } from '../../src/types';
+
+const STARTED_AT = 1_700_000_000_000;
+
+function designMessage(overrides: Partial = {}): ChatMessage {
+ return {
+ id: 'assistant-1',
+ role: 'assistant',
+ content: '',
+ sessionMode: 'design',
+ runStatus: 'running',
+ startedAt: STARTED_AT,
+ createdAt: STARTED_AT,
+ ...overrides,
+ };
+}
+
+describe('preview run status', () => {
+ it('uses real timestamps for an active Design run instead of a fake percentage', () => {
+ const status = latestPreviewRunStatus([designMessage()], STARTED_AT + 67_000);
+
+ expect(status).toMatchObject({ phase: 'generating', stage: 'analyzing', elapsedMs: 67_000 });
+ expect(formatPreviewRunElapsed(status?.elapsedMs ?? 0)).toBe('1:07');
+ });
+
+ it('uses real agent activity to advance from analysis to design generation', () => {
+ const analyzing = latestPreviewRunStatus(
+ [designMessage({ events: [{ kind: 'thinking', text: 'Reviewing the brief.' }] })],
+ STARTED_AT + 1_000,
+ );
+ const generating = latestPreviewRunStatus(
+ [
+ designMessage({
+ events: [{ kind: 'tool_use', id: 'tool-1', name: 'write_file', input: {} }],
+ }),
+ ],
+ STARTED_AT + 2_000,
+ );
+
+ expect(analyzing?.stage).toBe('analyzing');
+ expect(generating?.stage).toBe('generating');
+ });
+
+ it.each(['running', 'model', 'working'])(
+ 'keeps the run in analysis for the neutral %s lifecycle status',
+ (label) => {
+ const status = latestPreviewRunStatus(
+ [designMessage({ events: [{ kind: 'status', label }] })],
+ STARTED_AT + 1_000,
+ );
+
+ expect(status?.stage).toBe('analyzing');
+ },
+ );
+
+ it('waits to verify delivery before the final file refresh has resolved', () => {
+ const status = latestPreviewRunStatus(
+ [
+ designMessage({
+ runStatus: 'succeeded',
+ producedFiles: undefined,
+ traceObjectFiles: undefined,
+ }),
+ ],
+ STARTED_AT + 2_000,
+ );
+
+ expect(status).toMatchObject({ phase: 'verifying', stage: 'verifying' });
+ expect(status && previewRunStatusVisibleAt(status, STARTED_AT + 2_000)).toBe(true);
+ });
+
+ it('holds success briefly, then lets the surface fade away', () => {
+ const endedAt = STARTED_AT + 5_000;
+ const status = latestPreviewRunStatus(
+ [designMessage({ runStatus: 'succeeded', endedAt, resultDeliveryState: 'delivered' })],
+ endedAt,
+ );
+
+ expect(status?.phase).toBe('succeeded');
+ expect(status && previewRunStatusVisibleAt(status, endedAt + PREVIEW_RUN_SUCCESS_VISIBLE_MS - 1)).toBe(true);
+ expect(status && previewRunStatusVisibleAt(status, endedAt + PREVIEW_RUN_SUCCESS_VISIBLE_MS)).toBe(false);
+ });
+
+ it('does not restore an old delivered confirmation without an end timestamp', () => {
+ const status = latestPreviewRunStatus(
+ [
+ designMessage({
+ runStatus: 'succeeded',
+ resultDeliveryState: 'delivered',
+ endedAt: undefined,
+ }),
+ ],
+ STARTED_AT + PREVIEW_RUN_SUCCESS_VISIBLE_MS + 1,
+ );
+
+ expect(status?.phase).toBe('succeeded');
+ expect(
+ status && previewRunStatusVisibleAt(status, STARTED_AT + PREVIEW_RUN_SUCCESS_VISIBLE_MS + 1),
+ ).toBe(false);
+ });
+
+ it('keeps delivery failures visible and selects the latest persisted turn after a return', () => {
+ const oldRunning = designMessage({ id: 'assistant-running', startedAt: STARTED_AT - 10_000 });
+ const deliveryFailure = designMessage({
+ id: 'assistant-delivery-failure',
+ runStatus: 'succeeded',
+ resultDeliveryState: 'delivery_failed',
+ startedAt: STARTED_AT,
+ endedAt: STARTED_AT + 32_000,
+ });
+
+ const restored = latestPreviewRunStatus([oldRunning, deliveryFailure], STARTED_AT + 80_000);
+
+ expect(restored).toMatchObject({
+ phase: 'failed',
+ message: { id: 'assistant-delivery-failure', resultDeliveryState: 'delivery_failed' },
+ elapsedMs: 32_000,
+ });
+ expect(restored && previewRunStatusVisibleAt(restored, STARTED_AT + 80_000)).toBe(true);
+ });
+
+ it('does not claim that a clarification-only Design turn was delivered', () => {
+ expect(
+ latestPreviewRunStatus(
+ [
+ designMessage({
+ id: 'earlier-delivery-failure',
+ runStatus: 'succeeded',
+ resultDeliveryState: 'no_result',
+ endedAt: STARTED_AT + 500,
+ }),
+ designMessage({
+ id: 'clarification-turn',
+ runStatus: 'succeeded',
+ content: '{"questions":[]} ',
+ endedAt: STARTED_AT + 1_000,
+ }),
+ ],
+ STARTED_AT + 2_000,
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/e2e/ui/preview-run-status.test.ts b/e2e/ui/preview-run-status.test.ts
new file mode 100644
index 0000000000..83a7412085
--- /dev/null
+++ b/e2e/ui/preview-run-status.test.ts
@@ -0,0 +1,124 @@
+import { expect, test } from '@/playwright/suite';
+import { createFakeAgentRuntimes } from '@/playwright/fake-agents';
+import {
+ createProjectViaApi,
+ gotoEntryHome,
+ gotoProject,
+ putAppConfig,
+ seedBrowserConfig,
+} from '@/playwright/amr';
+import { T } from '@/timeouts';
+
+let codexEnv: Record;
+
+test.beforeAll(async () => {
+ const runtimes = await createFakeAgentRuntimes(['codex']);
+ codexEnv = runtimes.codex.env;
+});
+
+test('[P1] preview delivery status keeps persisted delivery-failure recovery in Chat', async ({ page }) => {
+ test.setTimeout(T.xlong);
+ const projectId = `preview-run-status-${Date.now()}`;
+ const now = Date.now();
+
+ const browserConfig = {
+ mode: 'daemon',
+ apiKey: '',
+ baseUrl: 'https://api.anthropic.com',
+ model: 'claude-sonnet-4-5',
+ agentId: 'codex',
+ skillId: null,
+ designSystemId: null,
+ onboardingCompleted: true,
+ agentModels: { codex: { model: 'default', reasoning: 'default' } },
+ agentCliEnv: { codex: codexEnv },
+ };
+ await seedBrowserConfig(page, browserConfig);
+ await putAppConfig(page, browserConfig);
+
+ const { conversationId } = await createProjectViaApi(page, projectId, 'Preview status recovery');
+ const existingFileResponse = await page.request.post(`/api/projects/${projectId}/files`, {
+ data: {
+ name: 'existing-design.html',
+ content: 'Existing design
',
+ },
+ });
+ expect(existingFileResponse.ok(), await existingFileResponse.text()).toBeTruthy();
+ const userResponse = await page.request.put(
+ `/api/projects/${projectId}/conversations/${conversationId}/messages/user-preview-status`,
+ {
+ data: {
+ role: 'user',
+ content: 'Create an editorial landing page',
+ createdAt: now - 50_000,
+ },
+ },
+ );
+ expect(userResponse.ok(), await userResponse.text()).toBeTruthy();
+
+ const failedResponse = await page.request.put(
+ `/api/projects/${projectId}/conversations/${conversationId}/messages/assistant-preview-status`,
+ {
+ data: {
+ role: 'assistant',
+ content: 'The design result was not delivered.',
+ agentId: 'codex',
+ sessionMode: 'design',
+ runId: 'run-preview-status',
+ runStatus: 'succeeded',
+ resultDeliveryState: 'delivery_failed',
+ createdAt: now - 50_000,
+ startedAt: now - 45_000,
+ endedAt: now - 3_000,
+ preTurnFileNames: ['existing-design.html'],
+ events: [{ kind: 'status', label: 'error', detail: 'The design result was not delivered.' }],
+ },
+ },
+ );
+ expect(failedResponse.ok(), await failedResponse.text()).toBeTruthy();
+
+ await gotoProject(page, projectId);
+ const status = page.getByTestId('preview-run-status');
+ await expect(status).toContainText('Delivery needs attention');
+ await expect(status.locator('xpath=ancestor::*[contains(@class, "ws-preview-run-status-slot")]')).toHaveCount(1);
+ await expect(status.locator('xpath=ancestor::*[@data-testid="design-files-empty"]')).toHaveCount(0);
+ await expect(status).not.toContainText('Elapsed');
+ await expect(page.getByTestId('preview-run-status-retry')).toHaveCount(0);
+ await expect(page.getByTestId('preview-run-status-view-details')).toBeVisible();
+ const chatRetry = page.locator('.chat-error-retry');
+ await expect(chatRetry).toBeVisible();
+
+ // A small persisted preview hint can bring users back to the full Chat
+ // failure card, which remains the sole retry entry point after navigation.
+ await gotoEntryHome(page);
+ await gotoProject(page, projectId);
+ await expect(page.getByTestId('preview-run-status')).toContainText('Delivery needs attention');
+ await expect(page.getByTestId('preview-run-status')).not.toContainText('Elapsed');
+ await expect(page.locator('.chat-error-retry')).toBeVisible();
+
+ await page.locator('.chat-error-retry').click();
+ await expect
+ .poll(async () => {
+ const response = await page.request.get(
+ `/api/projects/${projectId}/conversations/${conversationId}/messages`,
+ );
+ if (!response.ok()) return [];
+ const body = (await response.json()) as { messages?: Array<{ role: string; runStatus?: string }> };
+ return body.messages?.filter((message) => message.role === 'assistant') ?? [];
+ }, { timeout: T.long })
+ .toHaveLength(2);
+
+ await expect
+ .poll(async () => {
+ const response = await page.request.get(`/api/projects/${projectId}/files`);
+ if (!response.ok()) return [];
+ const body = (await response.json()) as { files?: Array<{ name: string }> };
+ return body.files?.map((file) => file.name) ?? [];
+ }, { timeout: T.long })
+ .toContain('fake-agent-runtime-codex.html');
+
+ await expect(page.getByTestId('preview-run-status')).toContainText('Design ready');
+ await expect(
+ page.getByTestId('preview-run-status').locator('xpath=ancestor::*[contains(@class, "ws-preview-run-status-slot")]'),
+ ).toHaveCount(1);
+});
diff --git a/packages/contracts/src/analytics/events/surface-view.ts b/packages/contracts/src/analytics/events/surface-view.ts
index 14e5ffd247..1aff4a9d37 100644
--- a/packages/contracts/src/analytics/events/surface-view.ts
+++ b/packages/contracts/src/analytics/events/surface-view.ts
@@ -89,6 +89,22 @@ export interface RunFailedToastSurfaceViewProps {
run_id: string | null;
}
+// Preview-workspace status feedback for Design-mode runs. This exposure is
+// intentionally separate from `run_finished`: that event records the daemon
+// outcome, while this one measures whether the user actually saw the delivery
+// confirmation or recovery path.
+export interface PreviewRunStatusSurfaceViewProps {
+ page_name: 'file_manager';
+ area: 'preview_run_status';
+ element: 'run_status_bar';
+ status: 'generating' | 'verifying' | 'succeeded' | 'failed';
+ delivery_state?: 'delivered' | 'no_result' | 'delivery_failed';
+ project_id: string;
+ conversation_id: string | null;
+ assistant_message_id: string;
+ run_id?: string;
+}
+
export interface AssistantFeedbackReasonPanelSurfaceViewProps {
page_name: 'chat_panel';
area: 'chat_panel';
@@ -197,6 +213,7 @@ export interface StudioOnboardingHintSurfaceViewProps {
export type SurfaceViewProps =
| RunFailedToastSurfaceViewProps
+ | PreviewRunStatusSurfaceViewProps
| HomeRecommendationSurfaceViewProps
| StudioOnboardingHintSurfaceViewProps
| HelpPopoverSurfaceViewProps
diff --git a/packages/contracts/src/analytics/events/ui-click.ts b/packages/contracts/src/analytics/events/ui-click.ts
index ed422ac35c..31664f8371 100644
--- a/packages/contracts/src/analytics/events/ui-click.ts
+++ b/packages/contracts/src/analytics/events/ui-click.ts
@@ -905,6 +905,18 @@ export interface RunFailedToastClickProps {
element: 'go_amr';
}
+export interface PreviewRunStatusClickProps {
+ page_name: 'file_manager';
+ area: 'preview_run_status';
+ element: 'view_details';
+ status: 'generating' | 'verifying' | 'succeeded' | 'failed';
+ delivery_state?: 'delivered' | 'no_result' | 'delivery_failed';
+ project_id: string;
+ conversation_id: string | null;
+ assistant_message_id: string;
+ run_id?: string;
+}
+
export interface AmrEntryClickProps {
page_name: TrackingPageName;
area: 'amr_entry';
@@ -1573,6 +1585,7 @@ export type UiClickProps =
| NextStepActionClickProps
| QuestionsFormClickProps
| RunFailedToastClickProps
+ | PreviewRunStatusClickProps
| AmrEntryClickProps
| ChatPanelResourcesPopoverClickProps
| ChatPanelMessageQueueClickProps
@@ -1606,4 +1619,3 @@ export type UiClickProps =
| SettingsDesignReviewClickProps
| SettingsExternalMcpClickProps
| OnboardingClickProps;
-