mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[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:
co-authored by
Claude Fable 5.1
parent
5d5a58fc29
commit
b0f884ab34
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user