From ca20d6f38ef50cf27776344796a5c7d77ce20eba Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 22:01:46 -0700 Subject: [PATCH] [eric] browser: one safe capture helper; a half-ready webview no longer crashes the app --- .../AgentChat/shell/InlineSurfaceEmbeds.tsx | 15 +++--------- .../app/pages/Dashboard/cards/AgentCard.tsx | 12 ++++------ .../pages/Dashboard/desktop/DesktopDock.tsx | 13 ++++------- frontend/src/shared/captureBrowserShot.ts | 23 +++++++++++++++++++ 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 frontend/src/shared/captureBrowserShot.ts diff --git a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx index 425099b7..baef4ffb 100644 --- a/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx +++ b/frontend/src/app/pages/AgentChat/shell/InlineSurfaceEmbeds.tsx @@ -7,7 +7,7 @@ import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import OpenInFullRoundedIcon from '@mui/icons-material/OpenInFullRounded'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { clearTiledCard, focusBrowserCard, focusViewCard } from '@/shared/state/dashboardLayoutSlice'; -import { getWebview } from '@/shared/browserRegistry'; +import { captureBrowserShot } from '@/shared/captureBrowserShot'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; // The desktop-redesign frame: the surfaces an agent is driving live INSIDE its chat. Each linked @@ -23,17 +23,8 @@ function useBrowserSnapshot(browserId: string, live: boolean): string | null { let dead = false; let timer = 0; const grab = async (): Promise => { - try { - const wv = getWebview(browserId) as unknown as { capturePage?: () => Promise<{ toDataURL(): string }> } | undefined; - if (wv?.capturePage) { - // capturePage can hang on off-screen guests (Electron 42); race a timeout so the poll never wedges. - const img = await Promise.race([ - wv.capturePage(), - new Promise((res) => window.setTimeout(() => res(null), 1200)), - ]); - if (!dead && img) setShot(img.toDataURL()); - } - } catch { /* snapshot is best-effort */ } + const img = await captureBrowserShot(browserId); + if (!dead && img) setShot(img); // Live sessions repaint forever; idle ones retry a few times so a webview that mounts late // (fullscreen hiding the canvas, load races) still yields one real frame instead of a blank. tries += 1; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index cee6384c..b837d692 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -43,7 +43,7 @@ import { openCardContextMenu } from '../desktop/CardContextMenu'; import { extractLatestTodos } from '../desktop/agentTodos'; import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; -import { getWebview } from '@/shared/browserRegistry'; +import { captureBrowserShot } from '@/shared/captureBrowserShot'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; @@ -754,13 +754,9 @@ const AgentCard: React.FC = ({ } let cancelled = false; const capture = (): void => { - const wv = getWebview(spawnedBrowserId); - const p = wv?.capturePage?.(); - if (p && typeof (p as Promise).then === 'function') { - (p as Promise<{ toDataURL(): string }>) - .then((img) => { if (!cancelled) setBrowserShot(img.toDataURL()); }) - .catch(() => undefined); - } + void captureBrowserShot(spawnedBrowserId).then((shot) => { + if (!cancelled && shot) setBrowserShot(shot); + }); }; capture(); // Refresh while the agent is driving so the shot tracks the page; parked cards keep the last frame. diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 8e6f7997..3930c925 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -8,7 +8,7 @@ import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayo import SettingsIcon from '@mui/icons-material/Settings'; import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import { useAppDispatch } from '@/shared/hooks'; -import { getWebview } from '@/shared/browserRegistry'; +import { captureBrowserShot } from '@/shared/captureBrowserShot'; import { buildDockEntries, CardRect, DockEntry } from './dockEntries'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { @@ -111,13 +111,10 @@ function DesktopDock({ hoverTimer.current = window.setTimeout(() => { setHovered({ id: entry.id, top }); if (entry.browserId) { - const wv = getWebview(entry.browserId); - const capture = wv?.capturePage?.(); - if (capture && typeof (capture as Promise).then === 'function') { - (capture as Promise<{ toDataURL(): string }>) - .then((img) => setLiveShot({ id: entry.id, dataUrl: img.toDataURL() })) - .catch(() => undefined); - } + const entryId = entry.id; + void captureBrowserShot(entry.browserId).then((shot) => { + if (shot) setLiveShot({ id: entryId, dataUrl: shot }); + }); } }, 220); }, diff --git a/frontend/src/shared/captureBrowserShot.ts b/frontend/src/shared/captureBrowserShot.ts new file mode 100644 index 00000000..25d3879f --- /dev/null +++ b/frontend/src/shared/captureBrowserShot.ts @@ -0,0 +1,23 @@ +import { getWebview } from './browserRegistry'; + +// One place for "get me a picture of a browser card, best effort". Four callers were hand-rolling +// it and three got it wrong in a different way, because capturePage fails in two shapes: it THROWS +// synchronously on a webview that is attached but not yet dom-ready (unguarded, that one took the +// whole dashboard down through AgentCard's ErrorBoundary), and it never settles at all on a guest +// the compositor is not drawing, so the timeout is not optional either. +const DEFAULT_SHOT_TIMEOUT_MS = 1200; + +/** A data URL of the card's live page, or null. Never throws, never hangs. */ +export async function captureBrowserShot(browserId: string, timeoutMs: number = DEFAULT_SHOT_TIMEOUT_MS): Promise { + try { + const wv = getWebview(browserId); + if (!wv?.capturePage) return null; + const image = await Promise.race([ + wv.capturePage(), + new Promise((resolve) => { window.setTimeout(() => resolve(null), timeoutMs); }), + ]); + return image && !image.isEmpty() ? image.toDataURL() : null; + } catch { + return null; + } +}