[eric] chat: an app built in the chat shows a live capture of its docked webview, and the view card persists a thumbnail when the owning turn ends; nothing ever wrote one (ENG-477)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-09-07 10:23:59 -07:00
co-authored by Claude Fable 5.1
parent 5d5a58fc29
commit b0f884ab34
3 changed files with 63 additions and 9 deletions
@@ -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:<id>`
// (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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
@@ -118,8 +120,8 @@ const AppEmbed: React.FC<{ c: ClaudeTokens; name: string; thumbnail: string | nu
<Typography sx={{ fontSize: '0.625rem', fontWeight: 600 }}>Open on canvas</Typography>
</Box>
</Box>
{thumbnail ? (
<Box component="img" src={thumbnail} alt="" sx={{ display: 'block', width: '100%', maxHeight: 260, objectFit: 'cover', objectPosition: 'top' }} />
{picture ? (
<Box component="img" src={picture} alt="" sx={{ display: 'block', width: '100%', maxHeight: 260, objectFit: 'cover', objectPosition: 'top' }} />
) : (
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.ghost, fontSize: '0.75rem' }}>
{live ? 'Building...' : 'Preview not captured yet'}
@@ -127,7 +129,8 @@ const AppEmbed: React.FC<{ c: ClaudeTokens; name: string; thumbnail: string | nu
)}
</Box>
</motion.div>
);
);
};
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
<AppEmbed
key={o.id}
c={c}
outputId={o.id}
name={o.name || 'App'}
thumbnail={o.thumbnail ?? null}
live={live}
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// The runner bundles tests into .test-build, so the sources are read from the mirrored src/ path (the browserSlotSize pin does the same).
const here = fileURLToPath(new URL('.', import.meta.url));
const srcDir = here.replace(/([\\/])\.test-build([\\/])/, '$1src$2');
const embedsPath = srcDir + 'InlineSurfaceEmbeds.tsx';
const cardPath = srcDir.replace(/AgentChat[\\/]shell[\\/]$/, 'Dashboard/cards/') + 'DashboardViewCard.tsx';
assert.ok(existsSync(embedsPath), `could not locate InlineSurfaceEmbeds.tsx from ${here}`);
assert.ok(existsSync(cardPath), `could not locate DashboardViewCard.tsx from ${here}`);
const embeds = readFileSync(embedsPath, 'utf8');
const card = readFileSync(cardPath, 'utf8');
// ENG-477: the embed read a thumbnail nothing wrote and said "Preview not captured yet" for the app's whole life.
test('the app embed captures the docked app webview by its registry id and falls back to the stored thumbnail', () => {
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/);
});
@@ -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<Props> = ({
// 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]);