mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(web): keep generation preview alive and persistent across waiting states
Address UX feedback on the generation preview surface: - Make the waiting card feel alive instead of frozen: breathing mark, sweeping progress shimmer, pulsing running-step dot, and a live activity snippet pulled from streamed events (respects prefers-reduced-motion). - Add an `awaiting-input` phase so the preview no longer reverts to the empty "design will appear here" placeholder when the agent asks the user a clarifying question (detects inline <question-form>). - Add a `stopped` phase so a canceled/paused run keeps a contextual paused card instead of blanking the surface. - Fix workspaceHasPreviewSurface live-artifact tab match (was reading a non-existent `tabId` field) and correct the unit assertion that contradicted the helper's `thinking` handling. - Populate generationPreview.* keys (incl. new awaiting/stopped strings) across all locales. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0450256ae5
commit
0eaf1c37d9
25 changed files with 617 additions and 42 deletions
|
|
@ -998,6 +998,15 @@ export function FileWorkspace({
|
|||
onConnectRepo={onConnectRepo}
|
||||
githubConnected={githubConnected}
|
||||
/>
|
||||
) : generationPreview ? (
|
||||
<GenerationPreviewStage
|
||||
model={generationPreview}
|
||||
onRetry={
|
||||
generationPreview.retryTarget && onRetry
|
||||
? () => onRetry(generationPreview.retryTarget!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : activeTab === DESIGN_FILES_TAB ? (
|
||||
<DesignFilesPanel
|
||||
key={projectId}
|
||||
|
|
@ -1098,15 +1107,6 @@ export function FileWorkspace({
|
|||
commentPortalId={commentPortalId}
|
||||
onCommentModeChange={onCommentModeChange}
|
||||
/>
|
||||
) : generationPreview ? (
|
||||
<GenerationPreviewStage
|
||||
model={generationPreview}
|
||||
onRetry={
|
||||
generationPreview.retryTarget && onRetry
|
||||
? () => onRetry(generationPreview.retryTarget!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="viewer-empty">
|
||||
{t('workspace.openFromDesignFiles')}{' '}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,36 @@
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* A gentle breathing halo so the card reads as "alive" while we wait,
|
||||
even between discrete progress events. */
|
||||
.mark[data-active='true'] {
|
||||
animation: markBreathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage[data-phase='failed'] .mark {
|
||||
border-color: color-mix(in srgb, var(--red) 32%, var(--border));
|
||||
background: color-mix(in srgb, var(--red) 8%, var(--bg-panel));
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stage[data-phase='stopped'] .mark {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes markBreathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
|
|
@ -32,12 +62,30 @@
|
|||
.lead {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
min-height: 21px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Live activity snippet: subtly de-emphasised + animated so streaming
|
||||
updates feel continuous rather than abrupt. */
|
||||
.lead[data-live='true'] {
|
||||
color: var(--text-faint);
|
||||
animation: leadFade 240ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes leadFade {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
width: min(360px, 100%);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
|
|
@ -54,6 +102,36 @@
|
|||
transition: width 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Indeterminate shimmer sweeping over the determinate fill so the bar
|
||||
keeps moving even when the percentage holds steady between events. */
|
||||
.progress[data-active='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--accent) 45%, transparent),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: progressSweep 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage[data-phase='failed'] .progress span {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@keyframes progressSweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -108,6 +186,32 @@
|
|||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.stepDot[data-running='true'] {
|
||||
opacity: 1;
|
||||
animation: stepPulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes stepPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mark[data-active='true'],
|
||||
.progress[data-active='true']::after,
|
||||
.stepDot[data-running='true'],
|
||||
.lead[data-live='true'] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
|
|||
const t = useT();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const generating = model.phase === 'generating';
|
||||
|
||||
useEffect(() => {
|
||||
if (model.failed) return undefined;
|
||||
if (!generating) return undefined;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [model.failed, model.startedAt]);
|
||||
}, [generating, model.startedAt]);
|
||||
|
||||
const elapsedSec = Math.max(0, Math.round((now - model.startedAt) / 1000));
|
||||
const elapsedLabel = formatGenerationElapsed(elapsedSec);
|
||||
|
|
@ -29,26 +31,45 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
|
|||
prepare: t('generationPreview.stepPrepare'),
|
||||
};
|
||||
|
||||
const title =
|
||||
model.phase === 'failed'
|
||||
? t('generationPreview.failedTitle')
|
||||
: model.phase === 'stopped'
|
||||
? t('generationPreview.stoppedTitle')
|
||||
: model.phase === 'awaiting-input'
|
||||
? t('generationPreview.awaitingTitle')
|
||||
: t('generationPreview.title');
|
||||
|
||||
const lead =
|
||||
model.phase === 'failed'
|
||||
? model.errorMessage || t('generationPreview.failedFallback')
|
||||
: model.phase === 'stopped'
|
||||
? t('generationPreview.stoppedLead')
|
||||
: model.phase === 'awaiting-input'
|
||||
? t('generationPreview.awaitingLead')
|
||||
: model.activityLabel || t('generationPreview.footnote');
|
||||
|
||||
const markIcon =
|
||||
model.phase === 'failed' ? 'close' : model.phase === 'stopped' ? 'stop' : 'sparkles';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={styles.stage}
|
||||
data-testid="generation-preview-stage"
|
||||
data-phase={model.phase}
|
||||
aria-live="polite"
|
||||
aria-busy={!model.failed}
|
||||
aria-busy={generating}
|
||||
>
|
||||
<div className={styles.mark} aria-hidden>
|
||||
<Icon name="sparkles" size={24} />
|
||||
<div className={styles.mark} data-active={generating} aria-hidden>
|
||||
<Icon name={markIcon} size={24} />
|
||||
</div>
|
||||
<h1 className={styles.title}>
|
||||
{model.failed ? t('generationPreview.failedTitle') : t('generationPreview.title')}
|
||||
</h1>
|
||||
<p className={styles.lead}>
|
||||
{model.failed
|
||||
? model.errorMessage || t('generationPreview.failedFallback')
|
||||
: t('generationPreview.footnote')}
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.lead} data-live={generating && Boolean(model.activityLabel)}>
|
||||
{lead}
|
||||
</p>
|
||||
<div
|
||||
className={styles.progress}
|
||||
data-active={generating}
|
||||
role="progressbar"
|
||||
aria-label={t('generationPreview.progressAria', { percent: model.progressPercent })}
|
||||
aria-valuemin={0}
|
||||
|
|
@ -66,23 +87,25 @@ export function GenerationPreviewStage({ model, onRetry }: Props) {
|
|||
) : step.status === 'failed' ? (
|
||||
<Icon name="close" size={12} />
|
||||
) : (
|
||||
<span className={styles.stepDot} />
|
||||
<span className={styles.stepDot} data-running={step.status === 'running' && generating} />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.stepLabel}>{stepLabels[step.id]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className={styles.meta}>
|
||||
<span data-testid="generation-preview-elapsed">
|
||||
{t('generationPreview.elapsed', { elapsed: elapsedLabel })}
|
||||
</span>
|
||||
<span className={styles.metaDivider} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>{t('generationPreview.estimate')}</span>
|
||||
</div>
|
||||
{model.failed && onRetry ? (
|
||||
{generating ? (
|
||||
<div className={styles.meta}>
|
||||
<span data-testid="generation-preview-elapsed">
|
||||
{t('generationPreview.elapsed', { elapsed: elapsedLabel })}
|
||||
</span>
|
||||
<span className={styles.metaDivider} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>{t('generationPreview.estimate')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{model.phase === 'failed' && onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retry}
|
||||
|
|
|
|||
|
|
@ -1741,4 +1741,19 @@ export const ar: Dict = {
|
|||
'diagnostics.exporting': 'جارٍ التصدير…',
|
||||
'diagnostics.exportSuccess': 'تم حفظ التشخيص في {path}',
|
||||
'diagnostics.exportFailed': 'تعذّر تصدير التشخيص: {message}',
|
||||
'generationPreview.title': 'جارٍ الإنشاء…',
|
||||
'generationPreview.failedTitle': 'فشل الإنشاء',
|
||||
'generationPreview.failedFallback': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
'generationPreview.footnote': 'يستغرق عادةً من 2 إلى 5 دقائق',
|
||||
'generationPreview.stepUnderstand': 'فهم المتطلبات',
|
||||
'generationPreview.stepGenerate': 'إنشاء الصفحة',
|
||||
'generationPreview.stepPrepare': 'تحضير المعاينة',
|
||||
'generationPreview.elapsed': 'مضى {elapsed}',
|
||||
'generationPreview.estimate': 'عادةً 2–5 دقائق',
|
||||
'generationPreview.progressAria': 'تقدّم الإنشاء: {percent}%',
|
||||
'generationPreview.retry': 'إعادة المحاولة',
|
||||
'generationPreview.awaitingTitle': 'في انتظار ردّك',
|
||||
'generationPreview.awaitingLead': 'أجب عن بعض الأسئلة في المحادثة للمتابعة.',
|
||||
'generationPreview.stoppedTitle': 'تم إيقاف الإنشاء مؤقتًا',
|
||||
'generationPreview.stoppedLead': 'تابع الخطوات المتبقية من المحادثة على اليسار.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1678,4 +1678,19 @@ export const de: Dict = {
|
|||
'diagnostics.exporting': 'Exportiere…',
|
||||
'diagnostics.exportSuccess': 'Diagnose gespeichert: {path}',
|
||||
'diagnostics.exportFailed': 'Diagnose-Export fehlgeschlagen: {message}',
|
||||
'generationPreview.title': 'Wird generiert…',
|
||||
'generationPreview.failedTitle': 'Generierung fehlgeschlagen',
|
||||
'generationPreview.failedFallback': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
|
||||
'generationPreview.footnote': 'Dauert normalerweise 2–5 Minuten',
|
||||
'generationPreview.stepUnderstand': 'Anforderungen verstehen',
|
||||
'generationPreview.stepGenerate': 'Seite generieren',
|
||||
'generationPreview.stepPrepare': 'Vorschau vorbereiten',
|
||||
'generationPreview.elapsed': '{elapsed} vergangen',
|
||||
'generationPreview.estimate': 'Normalerweise 2–5 Min.',
|
||||
'generationPreview.progressAria': 'Generierungsfortschritt: {percent}%',
|
||||
'generationPreview.retry': 'Erneut versuchen',
|
||||
'generationPreview.awaitingTitle': 'Warten auf deine Eingabe',
|
||||
'generationPreview.awaitingLead': 'Beantworte ein paar kurze Fragen im Chat, um fortzufahren.',
|
||||
'generationPreview.stoppedTitle': 'Generierung pausiert',
|
||||
'generationPreview.stoppedLead': 'Setze die verbleibenden Schritte im Chat links fort.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1528,6 +1528,10 @@ export const en: Dict = {
|
|||
'generationPreview.estimate': 'Usually 2–5 min',
|
||||
'generationPreview.progressAria': 'Generation progress: {percent}%',
|
||||
'generationPreview.retry': 'Retry',
|
||||
'generationPreview.awaitingTitle': 'Waiting for your input',
|
||||
'generationPreview.awaitingLead': 'Answer a few quick questions in the chat to continue.',
|
||||
'generationPreview.stoppedTitle': 'Generation paused',
|
||||
'generationPreview.stoppedLead': 'Resume the remaining steps from the chat on the left.',
|
||||
'designFiles.title': 'Design Files',
|
||||
'designFiles.upload': 'Upload files',
|
||||
'designFiles.pasteText': 'Paste as text file',
|
||||
|
|
|
|||
|
|
@ -1629,4 +1629,19 @@ export const esES: Dict = {
|
|||
'diagnostics.exporting': 'Exportando…',
|
||||
'diagnostics.exportSuccess': 'Diagnósticos guardados en {path}',
|
||||
'diagnostics.exportFailed': 'No se pudieron exportar los diagnósticos: {message}',
|
||||
'generationPreview.title': 'Generando…',
|
||||
'generationPreview.failedTitle': 'Error de generación',
|
||||
'generationPreview.failedFallback': 'Algo salió mal. Inténtalo de nuevo.',
|
||||
'generationPreview.footnote': 'Suele tardar de 2 a 5 minutos',
|
||||
'generationPreview.stepUnderstand': 'Entendiendo los requisitos',
|
||||
'generationPreview.stepGenerate': 'Generando la página',
|
||||
'generationPreview.stepPrepare': 'Preparando la vista previa',
|
||||
'generationPreview.elapsed': '{elapsed} transcurridos',
|
||||
'generationPreview.estimate': 'Normalmente 2–5 min',
|
||||
'generationPreview.progressAria': 'Progreso de la generación: {percent}%',
|
||||
'generationPreview.retry': 'Reintentar',
|
||||
'generationPreview.awaitingTitle': 'Esperando tu respuesta',
|
||||
'generationPreview.awaitingLead': 'Responde unas preguntas en el chat para continuar.',
|
||||
'generationPreview.stoppedTitle': 'Generación en pausa',
|
||||
'generationPreview.stoppedLead': 'Reanuda los pasos restantes desde el chat de la izquierda.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1783,4 +1783,19 @@ export const fa: Dict = {
|
|||
'diagnostics.exporting': 'در حال صادر کردن…',
|
||||
'diagnostics.exportSuccess': 'تشخیص در {path} ذخیره شد',
|
||||
'diagnostics.exportFailed': 'صادر کردن تشخیص ناموفق بود: {message}',
|
||||
'generationPreview.title': 'در حال ساخت…',
|
||||
'generationPreview.failedTitle': 'ساخت ناموفق بود',
|
||||
'generationPreview.failedFallback': 'مشکلی پیش آمد. لطفاً دوباره تلاش کنید.',
|
||||
'generationPreview.footnote': 'معمولاً ۲ تا ۵ دقیقه طول میکشد',
|
||||
'generationPreview.stepUnderstand': 'درک نیازمندیها',
|
||||
'generationPreview.stepGenerate': 'ساخت صفحه',
|
||||
'generationPreview.stepPrepare': 'آمادهسازی پیشنمایش',
|
||||
'generationPreview.elapsed': '{elapsed} گذشته',
|
||||
'generationPreview.estimate': 'معمولاً ۲ تا ۵ دقیقه',
|
||||
'generationPreview.progressAria': 'پیشرفت ساخت: {percent}%',
|
||||
'generationPreview.retry': 'تلاش دوباره',
|
||||
'generationPreview.awaitingTitle': 'در انتظار پاسخ شما',
|
||||
'generationPreview.awaitingLead': 'برای ادامه، به چند پرسش در گفتگو پاسخ دهید.',
|
||||
'generationPreview.stoppedTitle': 'ساخت متوقف شد',
|
||||
'generationPreview.stoppedLead': 'مراحل باقیمانده را از گفتگوی سمت چپ ادامه دهید.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2390,4 +2390,19 @@ export const fr: Dict = {
|
|||
'diagnostics.exporting': 'Exportation…',
|
||||
'diagnostics.exportSuccess': 'Diagnostic enregistré dans {path}',
|
||||
'diagnostics.exportFailed': 'Impossible d\'exporter le diagnostic: {message}',
|
||||
'generationPreview.title': 'Génération…',
|
||||
'generationPreview.failedTitle': 'Échec de la génération',
|
||||
'generationPreview.failedFallback': 'Une erreur est survenue. Veuillez réessayer.',
|
||||
'generationPreview.footnote': 'Prend généralement 2 à 5 minutes',
|
||||
'generationPreview.stepUnderstand': 'Compréhension des besoins',
|
||||
'generationPreview.stepGenerate': 'Génération de la page',
|
||||
'generationPreview.stepPrepare': 'Préparation de l\'aperçu',
|
||||
'generationPreview.elapsed': '{elapsed} écoulées',
|
||||
'generationPreview.estimate': 'Généralement 2–5 min',
|
||||
'generationPreview.progressAria': 'Progression de la génération : {percent}%',
|
||||
'generationPreview.retry': 'Réessayer',
|
||||
'generationPreview.awaitingTitle': 'En attente de votre réponse',
|
||||
'generationPreview.awaitingLead': 'Répondez à quelques questions dans le chat pour continuer.',
|
||||
'generationPreview.stoppedTitle': 'Génération en pause',
|
||||
'generationPreview.stoppedLead': 'Reprenez les étapes restantes depuis le chat à gauche.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1750,4 +1750,19 @@ export const hu: Dict = {
|
|||
'diagnostics.exporting': 'Exportálás…',
|
||||
'diagnostics.exportSuccess': 'Diagnosztika mentve: {path}',
|
||||
'diagnostics.exportFailed': 'Diagnosztika exportálása sikertelen: {message}',
|
||||
'generationPreview.title': 'Generálás…',
|
||||
'generationPreview.failedTitle': 'A generálás sikertelen',
|
||||
'generationPreview.failedFallback': 'Hiba történt. Kérjük, próbáld újra.',
|
||||
'generationPreview.footnote': 'Általában 2–5 percet vesz igénybe',
|
||||
'generationPreview.stepUnderstand': 'Követelmények értelmezése',
|
||||
'generationPreview.stepGenerate': 'Oldal generálása',
|
||||
'generationPreview.stepPrepare': 'Előnézet előkészítése',
|
||||
'generationPreview.elapsed': '{elapsed} eltelt',
|
||||
'generationPreview.estimate': 'Általában 2–5 perc',
|
||||
'generationPreview.progressAria': 'Generálás állapota: {percent}%',
|
||||
'generationPreview.retry': 'Újra',
|
||||
'generationPreview.awaitingTitle': 'Várakozás a válaszodra',
|
||||
'generationPreview.awaitingLead': 'Válaszolj néhány kérdésre a csevegésben a folytatáshoz.',
|
||||
'generationPreview.stoppedTitle': 'Generálás szüneteltetve',
|
||||
'generationPreview.stoppedLead': 'Folytasd a hátralévő lépéseket a bal oldali csevegésből.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1783,4 +1783,19 @@ export const id: Dict = {
|
|||
'diagnostics.exporting': 'Mengekspor…',
|
||||
'diagnostics.exportSuccess': 'Diagnostik disimpan di {path}',
|
||||
'diagnostics.exportFailed': 'Gagal mengekspor diagnostik: {message}',
|
||||
'generationPreview.title': 'Membuat…',
|
||||
'generationPreview.failedTitle': 'Pembuatan gagal',
|
||||
'generationPreview.failedFallback': 'Terjadi kesalahan. Silakan coba lagi.',
|
||||
'generationPreview.footnote': 'Biasanya butuh 2–5 menit',
|
||||
'generationPreview.stepUnderstand': 'Memahami kebutuhan',
|
||||
'generationPreview.stepGenerate': 'Membuat halaman',
|
||||
'generationPreview.stepPrepare': 'Menyiapkan pratinjau',
|
||||
'generationPreview.elapsed': '{elapsed} berlalu',
|
||||
'generationPreview.estimate': 'Biasanya 2–5 mnt',
|
||||
'generationPreview.progressAria': 'Progres pembuatan: {percent}%',
|
||||
'generationPreview.retry': 'Coba lagi',
|
||||
'generationPreview.awaitingTitle': 'Menunggu masukan Anda',
|
||||
'generationPreview.awaitingLead': 'Jawab beberapa pertanyaan di obrolan untuk melanjutkan.',
|
||||
'generationPreview.stoppedTitle': 'Pembuatan dijeda',
|
||||
'generationPreview.stoppedLead': 'Lanjutkan langkah yang tersisa dari obrolan di kiri.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1607,4 +1607,19 @@ export const it: Dict = {
|
|||
'liveArtifact.viewer.code.loading': 'Caricamento codice…',
|
||||
'liveArtifact.viewer.code.unavailable': 'Il codice non è ancora disponibile.',
|
||||
'liveArtifact.viewer.code.empty': 'Questo file di codice è vuoto.',
|
||||
'generationPreview.title': 'Generazione…',
|
||||
'generationPreview.failedTitle': 'Generazione non riuscita',
|
||||
'generationPreview.failedFallback': 'Qualcosa è andato storto. Riprova.',
|
||||
'generationPreview.footnote': 'Di solito richiede 2–5 minuti',
|
||||
'generationPreview.stepUnderstand': 'Analisi dei requisiti',
|
||||
'generationPreview.stepGenerate': 'Generazione della pagina',
|
||||
'generationPreview.stepPrepare': 'Preparazione dell\'anteprima',
|
||||
'generationPreview.elapsed': '{elapsed} trascorsi',
|
||||
'generationPreview.estimate': 'Di solito 2–5 min',
|
||||
'generationPreview.progressAria': 'Avanzamento della generazione: {percent}%',
|
||||
'generationPreview.retry': 'Riprova',
|
||||
'generationPreview.awaitingTitle': 'In attesa della tua risposta',
|
||||
'generationPreview.awaitingLead': 'Rispondi ad alcune domande nella chat per continuare.',
|
||||
'generationPreview.stoppedTitle': 'Generazione in pausa',
|
||||
'generationPreview.stoppedLead': 'Riprendi i passaggi rimanenti dalla chat a sinistra.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1677,4 +1677,19 @@ export const ja: Dict = {
|
|||
'diagnostics.exporting': 'エクスポート中…',
|
||||
'diagnostics.exportSuccess': '診断情報を {path} に保存しました',
|
||||
'diagnostics.exportFailed': '診断情報のエクスポートに失敗しました: {message}',
|
||||
'generationPreview.title': '生成中…',
|
||||
'generationPreview.failedTitle': '生成に失敗しました',
|
||||
'generationPreview.failedFallback': '問題が発生しました。もう一度お試しください。',
|
||||
'generationPreview.footnote': '通常2〜5分かかります',
|
||||
'generationPreview.stepUnderstand': '要件を理解中',
|
||||
'generationPreview.stepGenerate': 'ページを生成中',
|
||||
'generationPreview.stepPrepare': 'プレビューを準備中',
|
||||
'generationPreview.elapsed': '経過 {elapsed}',
|
||||
'generationPreview.estimate': '通常2〜5分',
|
||||
'generationPreview.progressAria': '生成の進捗:{percent}%',
|
||||
'generationPreview.retry': '再試行',
|
||||
'generationPreview.awaitingTitle': '入力をお待ちしています',
|
||||
'generationPreview.awaitingLead': 'チャットでいくつかの質問に答えると続行します。',
|
||||
'generationPreview.stoppedTitle': '生成を一時停止しました',
|
||||
'generationPreview.stoppedLead': '左側のチャットから残りのステップを再開できます。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1790,4 +1790,19 @@ export const ko: Dict = {
|
|||
'diagnostics.exporting': '내보내는 중…',
|
||||
'diagnostics.exportSuccess': '진단 정보를 {path}에 저장했습니다',
|
||||
'diagnostics.exportFailed': '진단 정보 내보내기 실패: {message}',
|
||||
'generationPreview.title': '생성 중…',
|
||||
'generationPreview.failedTitle': '생성 실패',
|
||||
'generationPreview.failedFallback': '문제가 발생했습니다. 다시 시도해 주세요.',
|
||||
'generationPreview.footnote': '보통 2~5분 정도 걸립니다',
|
||||
'generationPreview.stepUnderstand': '요구사항 이해 중',
|
||||
'generationPreview.stepGenerate': '페이지 생성 중',
|
||||
'generationPreview.stepPrepare': '미리보기 준비 중',
|
||||
'generationPreview.elapsed': '경과 {elapsed}',
|
||||
'generationPreview.estimate': '보통 2~5분',
|
||||
'generationPreview.progressAria': '생성 진행률: {percent}%',
|
||||
'generationPreview.retry': '다시 시도',
|
||||
'generationPreview.awaitingTitle': '입력을 기다리는 중',
|
||||
'generationPreview.awaitingLead': '왼쪽 채팅에서 몇 가지 질문에 답하면 계속됩니다.',
|
||||
'generationPreview.stoppedTitle': '생성이 일시중지됨',
|
||||
'generationPreview.stoppedLead': '왼쪽 채팅에서 남은 단계를 다시 시작할 수 있습니다.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1740,4 +1740,19 @@ export const pl: Dict = {
|
|||
'diagnostics.exporting': 'Eksportowanie…',
|
||||
'diagnostics.exportSuccess': 'Diagnostyka zapisana w {path}',
|
||||
'diagnostics.exportFailed': 'Nie udało się wyeksportować diagnostyki: {message}',
|
||||
'generationPreview.title': 'Generowanie…',
|
||||
'generationPreview.failedTitle': 'Generowanie nie powiodło się',
|
||||
'generationPreview.failedFallback': 'Coś poszło nie tak. Spróbuj ponownie.',
|
||||
'generationPreview.footnote': 'Zwykle trwa 2–5 minut',
|
||||
'generationPreview.stepUnderstand': 'Analiza wymagań',
|
||||
'generationPreview.stepGenerate': 'Generowanie strony',
|
||||
'generationPreview.stepPrepare': 'Przygotowanie podglądu',
|
||||
'generationPreview.elapsed': 'Upłynęło {elapsed}',
|
||||
'generationPreview.estimate': 'Zwykle 2–5 min',
|
||||
'generationPreview.progressAria': 'Postęp generowania: {percent}%',
|
||||
'generationPreview.retry': 'Spróbuj ponownie',
|
||||
'generationPreview.awaitingTitle': 'Oczekiwanie na Twoją odpowiedź',
|
||||
'generationPreview.awaitingLead': 'Odpowiedz na kilka pytań na czacie, aby kontynuować.',
|
||||
'generationPreview.stoppedTitle': 'Generowanie wstrzymane',
|
||||
'generationPreview.stoppedLead': 'Wznów pozostałe kroki z czatu po lewej stronie.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1781,4 +1781,19 @@ export const ptBR: Dict = {
|
|||
'diagnostics.exporting': 'Exportando…',
|
||||
'diagnostics.exportSuccess': 'Diagnósticos salvos em {path}',
|
||||
'diagnostics.exportFailed': 'Falha ao exportar diagnósticos: {message}',
|
||||
'generationPreview.title': 'Gerando…',
|
||||
'generationPreview.failedTitle': 'Falha na geração',
|
||||
'generationPreview.failedFallback': 'Algo deu errado. Tente novamente.',
|
||||
'generationPreview.footnote': 'Normalmente leva de 2 a 5 minutos',
|
||||
'generationPreview.stepUnderstand': 'Entendendo os requisitos',
|
||||
'generationPreview.stepGenerate': 'Gerando a página',
|
||||
'generationPreview.stepPrepare': 'Preparando a prévia',
|
||||
'generationPreview.elapsed': '{elapsed} decorridos',
|
||||
'generationPreview.estimate': 'Normalmente 2–5 min',
|
||||
'generationPreview.progressAria': 'Progresso da geração: {percent}%',
|
||||
'generationPreview.retry': 'Tentar novamente',
|
||||
'generationPreview.awaitingTitle': 'Aguardando sua resposta',
|
||||
'generationPreview.awaitingLead': 'Responda algumas perguntas no chat para continuar.',
|
||||
'generationPreview.stoppedTitle': 'Geração pausada',
|
||||
'generationPreview.stoppedLead': 'Retome as etapas restantes pelo chat à esquerda.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1781,4 +1781,19 @@ export const ru: Dict = {
|
|||
'diagnostics.exporting': 'Экспортирование…',
|
||||
'diagnostics.exportSuccess': 'Диагностика сохранена: {path}',
|
||||
'diagnostics.exportFailed': 'Не удалось экспортировать диагностику: {message}',
|
||||
'generationPreview.title': 'Генерация…',
|
||||
'generationPreview.failedTitle': 'Ошибка генерации',
|
||||
'generationPreview.failedFallback': 'Что-то пошло не так. Попробуйте ещё раз.',
|
||||
'generationPreview.footnote': 'Обычно занимает 2–5 минут',
|
||||
'generationPreview.stepUnderstand': 'Анализ требований',
|
||||
'generationPreview.stepGenerate': 'Создание страницы',
|
||||
'generationPreview.stepPrepare': 'Подготовка предпросмотра',
|
||||
'generationPreview.elapsed': 'Прошло {elapsed}',
|
||||
'generationPreview.estimate': 'Обычно 2–5 мин',
|
||||
'generationPreview.progressAria': 'Прогресс генерации: {percent}%',
|
||||
'generationPreview.retry': 'Повторить',
|
||||
'generationPreview.awaitingTitle': 'Ожидание вашего ответа',
|
||||
'generationPreview.awaitingLead': 'Ответьте на несколько вопросов в чате, чтобы продолжить.',
|
||||
'generationPreview.stoppedTitle': 'Генерация приостановлена',
|
||||
'generationPreview.stoppedLead': 'Продолжите оставшиеся шаги в чате слева.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1573,4 +1573,19 @@ export const th: Dict = {
|
|||
'settings.designSystemsCategory': 'หมวดหมู่',
|
||||
'settings.designSystemsAllCategories': 'ทุกหมวดหมู่',
|
||||
'settings.designSystemsShowInHomeGallery': 'แสดงในแกลเลอรีหน้าแรก',
|
||||
'generationPreview.title': 'กำลังสร้าง…',
|
||||
'generationPreview.failedTitle': 'การสร้างล้มเหลว',
|
||||
'generationPreview.failedFallback': 'เกิดข้อผิดพลาด โปรดลองอีกครั้ง',
|
||||
'generationPreview.footnote': 'โดยปกติใช้เวลา 2–5 นาที',
|
||||
'generationPreview.stepUnderstand': 'กำลังทำความเข้าใจความต้องการ',
|
||||
'generationPreview.stepGenerate': 'กำลังสร้างหน้า',
|
||||
'generationPreview.stepPrepare': 'กำลังเตรียมตัวอย่าง',
|
||||
'generationPreview.elapsed': 'ผ่านไป {elapsed}',
|
||||
'generationPreview.estimate': 'โดยปกติ 2–5 นาที',
|
||||
'generationPreview.progressAria': 'ความคืบหน้าการสร้าง: {percent}%',
|
||||
'generationPreview.retry': 'ลองใหม่',
|
||||
'generationPreview.awaitingTitle': 'กำลังรอข้อมูลจากคุณ',
|
||||
'generationPreview.awaitingLead': 'ตอบคำถามสองสามข้อในแชทเพื่อดำเนินการต่อ',
|
||||
'generationPreview.stoppedTitle': 'หยุดการสร้างชั่วคราว',
|
||||
'generationPreview.stoppedLead': 'ดำเนินการขั้นตอนที่เหลือต่อจากแชทด้านซ้าย',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1727,4 +1727,19 @@ export const tr: Dict = {
|
|||
'diagnostics.exporting': 'Dışa aktarılıyor…',
|
||||
'diagnostics.exportSuccess': 'Tanılama {path} konumuna kaydedildi',
|
||||
'diagnostics.exportFailed': 'Tanılama dışa aktarılamadı: {message}',
|
||||
'generationPreview.title': 'Oluşturuluyor…',
|
||||
'generationPreview.failedTitle': 'Oluşturma başarısız',
|
||||
'generationPreview.failedFallback': 'Bir şeyler ters gitti. Lütfen tekrar deneyin.',
|
||||
'generationPreview.footnote': 'Genellikle 2–5 dakika sürer',
|
||||
'generationPreview.stepUnderstand': 'Gereksinimler anlaşılıyor',
|
||||
'generationPreview.stepGenerate': 'Sayfa oluşturuluyor',
|
||||
'generationPreview.stepPrepare': 'Önizleme hazırlanıyor',
|
||||
'generationPreview.elapsed': '{elapsed} geçti',
|
||||
'generationPreview.estimate': 'Genellikle 2–5 dk',
|
||||
'generationPreview.progressAria': 'Oluşturma ilerlemesi: %{percent}',
|
||||
'generationPreview.retry': 'Yeniden dene',
|
||||
'generationPreview.awaitingTitle': 'Yanıtınız bekleniyor',
|
||||
'generationPreview.awaitingLead': 'Devam etmek için sohbette birkaç soruyu yanıtlayın.',
|
||||
'generationPreview.stoppedTitle': 'Oluşturma duraklatıldı',
|
||||
'generationPreview.stoppedLead': 'Kalan adımları soldaki sohbetten sürdürün.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1783,4 +1783,19 @@ export const uk: Dict = {
|
|||
'diagnostics.exporting': 'Експортування…',
|
||||
'diagnostics.exportSuccess': 'Діагностику збережено: {path}',
|
||||
'diagnostics.exportFailed': 'Не вдалося експортувати діагностику: {message}',
|
||||
'generationPreview.title': 'Генерація…',
|
||||
'generationPreview.failedTitle': 'Помилка генерації',
|
||||
'generationPreview.failedFallback': 'Щось пішло не так. Спробуйте ще раз.',
|
||||
'generationPreview.footnote': 'Зазвичай триває 2–5 хвилин',
|
||||
'generationPreview.stepUnderstand': 'Аналіз вимог',
|
||||
'generationPreview.stepGenerate': 'Створення сторінки',
|
||||
'generationPreview.stepPrepare': 'Підготовка попереднього перегляду',
|
||||
'generationPreview.elapsed': 'Минуло {elapsed}',
|
||||
'generationPreview.estimate': 'Зазвичай 2–5 хв',
|
||||
'generationPreview.progressAria': 'Прогрес генерації: {percent}%',
|
||||
'generationPreview.retry': 'Повторити',
|
||||
'generationPreview.awaitingTitle': 'Очікування вашої відповіді',
|
||||
'generationPreview.awaitingLead': 'Дайте відповідь на кілька запитань у чаті, щоб продовжити.',
|
||||
'generationPreview.stoppedTitle': 'Генерацію призупинено',
|
||||
'generationPreview.stoppedLead': 'Продовжте решту кроків у чаті ліворуч.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2477,4 +2477,8 @@ export const zhCN: Dict = {
|
|||
'generationPreview.estimate': '通常需要 2–5 分钟',
|
||||
'generationPreview.progressAria': '生成进度:{percent}%',
|
||||
'generationPreview.retry': '重试',
|
||||
'generationPreview.awaitingTitle': '等待你的补充',
|
||||
'generationPreview.awaitingLead': '在左侧聊天里回答几个问题即可继续生成。',
|
||||
'generationPreview.stoppedTitle': '生成已暂停',
|
||||
'generationPreview.stoppedLead': '可在左侧聊天里继续未完成的步骤。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2041,4 +2041,19 @@ export const zhTW: Dict = {
|
|||
'skillPluginCandidate.publishRepo': '發布倉庫',
|
||||
'skillPluginCandidate.dismiss': '忽略',
|
||||
'skillPluginCandidate.repoDescription': '這個倉庫看起來可以做成外掛。',
|
||||
'generationPreview.title': '正在生成…',
|
||||
'generationPreview.failedTitle': '生成失敗',
|
||||
'generationPreview.failedFallback': '發生錯誤,請重試。',
|
||||
'generationPreview.footnote': '通常需要 2–5 分鐘',
|
||||
'generationPreview.stepUnderstand': '理解需求',
|
||||
'generationPreview.stepGenerate': '生成頁面',
|
||||
'generationPreview.stepPrepare': '準備預覽',
|
||||
'generationPreview.elapsed': '已等待 {elapsed}',
|
||||
'generationPreview.estimate': '通常需要 2–5 分鐘',
|
||||
'generationPreview.progressAria': '生成進度:{percent}%',
|
||||
'generationPreview.retry': '重試',
|
||||
'generationPreview.awaitingTitle': '等待你的補充',
|
||||
'generationPreview.awaitingLead': '在左側聊天裡回答幾個問題即可繼續生成。',
|
||||
'generationPreview.stoppedTitle': '生成已暫停',
|
||||
'generationPreview.stoppedLead': '可在左側聊天裡繼續未完成的步驟。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1844,6 +1844,10 @@ export interface Dict {
|
|||
'generationPreview.estimate': string;
|
||||
'generationPreview.progressAria': string;
|
||||
'generationPreview.retry': string;
|
||||
'generationPreview.awaitingTitle': string;
|
||||
'generationPreview.awaitingLead': string;
|
||||
'generationPreview.stoppedTitle': string;
|
||||
'generationPreview.stoppedLead': string;
|
||||
'designFiles.title': string;
|
||||
'designFiles.upload': string;
|
||||
'designFiles.pasteText': string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { AgentEvent, ChatMessage, LiveArtifactSummary, ProjectFile } from '../types';
|
||||
import { isLiveArtifactTabId } from '../types';
|
||||
import { isLiveArtifactTabId, liveArtifactTabId } from '../types';
|
||||
|
||||
export type GenerationStepStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
export type GenerationPhase = 'generating' | 'awaiting-input' | 'stopped' | 'failed';
|
||||
|
||||
export interface GenerationPreviewStep {
|
||||
id: 'understand' | 'generate' | 'prepare';
|
||||
status: GenerationStepStatus;
|
||||
|
|
@ -11,11 +13,22 @@ export interface GenerationPreviewStep {
|
|||
export interface GenerationPreviewModel {
|
||||
startedAt: number;
|
||||
steps: GenerationPreviewStep[];
|
||||
phase: GenerationPhase;
|
||||
failed: boolean;
|
||||
errorMessage: string | null;
|
||||
progressPercent: number;
|
||||
/**
|
||||
* Latest human-readable activity snippet pulled from the streamed
|
||||
* events. Only set while actively generating so the waiting surface
|
||||
* shows real movement instead of a frozen card.
|
||||
*/
|
||||
activityLabel: string | null;
|
||||
}
|
||||
|
||||
// Matches the inline forms the agent emits to ask the user clarifying
|
||||
// questions before continuing (see artifacts/question-form.ts).
|
||||
const QUESTION_FORM_RE = /<(question-form|ask-question)\b/i;
|
||||
|
||||
const PREVIEWABLE_FILE = /\.(html?|jsx|tsx|svg|md|pdf|pptx?|key)$/i;
|
||||
|
||||
export function workspaceHasPreviewSurface(input: {
|
||||
|
|
@ -28,7 +41,7 @@ export function workspaceHasPreviewSurface(input: {
|
|||
const active = input.activeTab;
|
||||
if (!active) return false;
|
||||
if (isLiveArtifactTabId(active)) {
|
||||
return input.liveArtifacts.some((entry) => entry.tabId === active);
|
||||
return input.liveArtifacts.some((entry) => liveArtifactTabId(entry.id) === active);
|
||||
}
|
||||
const file = input.projectFiles.find((item) => item.name === active);
|
||||
if (!file) return false;
|
||||
|
|
@ -85,17 +98,40 @@ export function buildGenerationPreviewState(input: {
|
|||
|
||||
if (!latestAssistant) return null;
|
||||
|
||||
const runActive = isActiveRunStatus(latestAssistant.runStatus) || input.streaming;
|
||||
const runFailed = latestAssistant.runStatus === 'failed';
|
||||
if (!runActive && !runFailed) return null;
|
||||
if (hasPreviewSurface && !runFailed) return null;
|
||||
const status = latestAssistant.runStatus;
|
||||
const runActive = isActiveRunStatus(status) || input.streaming;
|
||||
const runFailed = status === 'failed';
|
||||
const runStopped = status === 'canceled';
|
||||
// The agent finished its turn but is waiting on the user to answer an
|
||||
// inline question form before it can keep going.
|
||||
const awaitingInput =
|
||||
!runActive && !runFailed && !runStopped && messageHasPendingQuestion(latestAssistant);
|
||||
|
||||
let phase: GenerationPhase;
|
||||
if (runFailed) {
|
||||
phase = 'failed';
|
||||
} else if (runActive) {
|
||||
phase = 'generating';
|
||||
} else if (runStopped) {
|
||||
phase = 'stopped';
|
||||
} else if (awaitingInput) {
|
||||
phase = 'awaiting-input';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Once the user has something previewable, only the error state takes
|
||||
// over the surface; the calmer waiting states defer to the live preview
|
||||
// so we never hide a finished artifact behind a status card.
|
||||
if (hasPreviewSurface && phase !== 'failed') return null;
|
||||
|
||||
const failed = phase === 'failed';
|
||||
const events = latestAssistant.events ?? [];
|
||||
const derived = deriveGenerationPreviewModel({
|
||||
events,
|
||||
hasArtifactHtml: Boolean(input.artifactHtml?.trim()),
|
||||
hasPreviewSurface,
|
||||
failed: runFailed,
|
||||
failed,
|
||||
errorMessage: input.conversationError,
|
||||
});
|
||||
|
||||
|
|
@ -104,10 +140,12 @@ export function buildGenerationPreviewState(input: {
|
|||
return {
|
||||
startedAt,
|
||||
steps: derived.steps,
|
||||
failed: runFailed,
|
||||
phase,
|
||||
failed,
|
||||
errorMessage: derived.errorMessage,
|
||||
progressPercent: derived.progressPercent,
|
||||
retryTarget: runFailed ? latestAssistant : null,
|
||||
activityLabel: phase === 'generating' ? latestActivityLabel(events) : null,
|
||||
retryTarget: failed ? latestAssistant : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +224,35 @@ function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
|
|||
return status === 'queued' || status === 'running';
|
||||
}
|
||||
|
||||
function messageHasPendingQuestion(message: ChatMessage): boolean {
|
||||
if (typeof message.content === 'string' && QUESTION_FORM_RE.test(message.content)) {
|
||||
return true;
|
||||
}
|
||||
const events = message.events ?? [];
|
||||
return events.some((event) => event.kind === 'text' && QUESTION_FORM_RE.test(event.text));
|
||||
}
|
||||
|
||||
function latestActivityLabel(events: AgentEvent[]): string | null {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index]!;
|
||||
if (event.kind === 'thinking' && event.text.trim()) {
|
||||
return truncateActivity(event.text);
|
||||
}
|
||||
if (event.kind === 'text' && event.text.trim() && !QUESTION_FORM_RE.test(event.text)) {
|
||||
return truncateActivity(event.text);
|
||||
}
|
||||
if (event.kind === 'status' && event.detail?.trim()) {
|
||||
return truncateActivity(event.detail);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function truncateActivity(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, ' ').trim();
|
||||
return collapsed.length > 80 ? `${collapsed.slice(0, 79)}…` : collapsed;
|
||||
}
|
||||
|
||||
function eventsHaveStatus(events: AgentEvent[], labels: string[]): boolean {
|
||||
const normalized = new Set(labels.map((label) => label.toLowerCase()));
|
||||
return events.some(
|
||||
|
|
|
|||
|
|
@ -64,10 +64,124 @@ describe('generation preview helpers', () => {
|
|||
liveArtifacts: [],
|
||||
});
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.steps[0]?.status).toBe('running');
|
||||
expect(state?.phase).toBe('generating');
|
||||
// A `thinking` status is enough evidence that the model started, so the
|
||||
// first step has already advanced past "running".
|
||||
expect(state?.steps[0]?.status).toBe('succeeded');
|
||||
expect(state?.retryTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the latest activity snippet while generating', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'running',
|
||||
startedAt: Date.now(),
|
||||
events: [
|
||||
{ kind: 'status', label: 'thinking' },
|
||||
{ kind: 'thinking', text: 'Sketching the hero section layout' },
|
||||
],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: true,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.activityLabel).toBe('Sketching the hero section layout');
|
||||
});
|
||||
|
||||
it('keeps a paused surface when the run was stopped without a preview', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'Partial work',
|
||||
runStatus: 'canceled',
|
||||
startedAt: Date.now() - 10_000,
|
||||
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.phase).toBe('stopped');
|
||||
expect(state?.failed).toBe(false);
|
||||
expect(state?.retryTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a waiting surface when the agent is asking the user a question', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'A few quick questions:\n<question-form id="discovery" title="Brief">{"questions":[]}</question-form>',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: Date.now() - 4_000,
|
||||
events: [{ kind: 'text', text: '<question-form id="discovery">{"questions":[]}</question-form>' }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
expect(state?.phase).toBe('awaiting-input');
|
||||
expect(state?.retryTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a finished run that produced no question or preview', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'All done!',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: Date.now() - 4_000,
|
||||
events: [{ kind: 'text', text: 'All done!' }],
|
||||
};
|
||||
expect(
|
||||
buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a failed state with a retry target', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
runStatus: 'failed',
|
||||
startedAt: Date.now() - 8_000,
|
||||
events: [{ kind: 'text', text: 'Model request failed' }],
|
||||
};
|
||||
const state = buildGenerationPreviewState({
|
||||
designSystemProject: false,
|
||||
messages: [assistant],
|
||||
streaming: false,
|
||||
activeTab: null,
|
||||
projectFiles: [],
|
||||
liveArtifacts: [],
|
||||
conversationError: 'Network error',
|
||||
});
|
||||
expect(state?.phase).toBe('failed');
|
||||
expect(state?.failed).toBe(true);
|
||||
expect(state?.errorMessage).toBe('Network error');
|
||||
expect(state?.retryTarget).toBe(assistant);
|
||||
});
|
||||
|
||||
it('hides preview state once a preview tab is active', () => {
|
||||
const assistant: ChatMessage = {
|
||||
id: 'a1',
|
||||
|
|
|
|||
Loading…
Reference in a new issue