diff --git a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx index c44b4921..1e1c5926 100644 --- a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx +++ b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx @@ -85,12 +85,14 @@ const BrowserEmbed: React.FC<{ c: ClaudeTokens; browserId: string; title: string // An app built in this chat gets the same treatment as a browser: a titled frame with a real view -// of the thing, not a text row. It was a one-line link while browsers showed a live picture, which -// is the asymmetry Eric reported ("it should show that it's inside the agent like browser agents"). -// Uses the stored `thumbnail` rather than a live capture: app cards never register a webview in -// browserRegistry, so captureBrowserShot cannot see them, and inventing that path blind is how a -// preview becomes a renderer crash. -const AppEmbed: React.FC<{ c: ClaudeTokens; name: string; thumbnail: string | null; live: boolean; onOpen: () => void }> = ({ c, name, thumbnail, live, onOpen }) => ( +// of the thing, not a text row. The docked app's webview registers in browserRegistry as `app:` +// (ViewPreview), so the embed captures it on the browser cadence; the stored thumbnail is the +// fallback for an app whose card is not painting (undocked, resting, or on another dashboard). +// Nothing ever wrote that thumbnail before (ENG-477), so the view card persists one when its turn ends. +const AppEmbed: React.FC<{ c: ClaudeTokens; outputId: string; name: string; thumbnail: string | null; live: boolean; onOpen: () => void }> = ({ c, outputId, name, thumbnail, live, onOpen }) => { + const shot = useBrowserSnapshot(`app:${outputId}`, live); + const picture = shot ?? thumbnail; + return ( Open on canvas - {thumbnail ? ( - + {picture ? ( + ) : ( {live ? 'Building...' : 'Preview not captured yet'} @@ -127,7 +129,8 @@ const AppEmbed: React.FC<{ c: ClaudeTokens; name: string; thumbnail: string | nu )} -); + ); +}; const InlineSurfaceEmbeds: React.FC<{ c: ClaudeTokens; sessionId: string; fullscreen?: boolean }> = ({ c, sessionId, fullscreen }) => { const dispatch = useAppDispatch(); @@ -190,6 +193,7 @@ const InlineSurfaceEmbeds: React.FC<{ c: ClaudeTokens; sessionId: string; fullsc { + assert.match(embeds, /useBrowserSnapshot\(`app:\$\{outputId\}`, live\)/); + assert.match(embeds, /const picture = shot \?\? thumbnail;/); + assert.doesNotMatch(embeds, /app cards never register a webview/, 'the stale premise must not survive in a comment'); +}); + +test('the view card persists a thumbnail when the turn that owns the app ends', () => { + assert.match(card, /ownerTurnLive/); + assert.match(card, /dispatch\(updateOutput\(\{ id: output\.id, thumbnail: snap \}\)\)/); + assert.match(card, /THUMBNAIL_SETTLE_MS = 1500/); +}); diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 7337f4bd..6045a985 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -60,6 +60,8 @@ const inElectron = isElectron(); const APP_PREVIEW_MIN_PX = 260; // below this on-screen width the live page is indistinguishable from a still const APP_PREVIEW_MARGIN_PX = 400; // resume once the card is within this of the viewport const APP_SUSPEND_SETTLE_MS = 1200; +// A turn's last file write and the app's own hot reload land inside this; capture after, not during. +const THUMBNAIL_SETTLE_MS = 1500; @@ -161,6 +163,27 @@ const DashboardViewCard: React.FC = ({ // Reveal-born apps stay a light "click to open" card until the first click, so the onboarding curtain // lifts instantly instead of behind an in-frame live Vite boot. The click (selecting it) clears the flag. const previewDeferred = useAppSelector((s) => !!s.dashboardLayout.viewCards[cardKey]?.preview_deferred); + // The stored thumbnail feeds the chat's app embed and the app tile, and nothing ever wrote it (ENG-477): + // capture once when the turn that built or changed this app ends, on the dashboard's own JPEG size. + const ownerTurnLive = useAppSelector((s) => { + const st = output.session_id ? s.agents.sessions[output.session_id]?.status : undefined; + return st === 'running' || st === 'waiting_approval'; + }); + const ownerTurnWasLiveRef = useRef(false); + useEffect(() => { + if (ownerTurnLive) { ownerTurnWasLiveRef.current = true; return undefined; } + if (!ownerTurnWasLiveRef.current) return undefined; + ownerTurnWasLiveRef.current = false; + const timer = window.setTimeout(() => { + void (async () => { + try { + const snap = await previewRef.current?.capture?.(); + if (snap) dispatch(updateOutput({ id: output.id, thumbnail: snap })); + } catch { /* no frame: the embed keeps its live capture, the tile its placeholder */ } + })(); + }, THUMBNAIL_SETTLE_MS); + return () => window.clearTimeout(timer); + }, [ownerTurnLive, output.id, dispatch]); useEffect(() => { if (previewDeferred && (isSelected || interactive)) dispatch(activateViewCardPreview(cardKey)); }, [previewDeferred, isSelected, interactive, cardKey, dispatch]);