[eric] browser: one safe capture helper; a half-ready webview no longer crashes the app

This commit is contained in:
ciregenz
2026-07-31 22:01:46 -07:00
parent 9079931f87
commit ca20d6f38e
4 changed files with 35 additions and 28 deletions
@@ -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<void> => {
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<null>((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;
@@ -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<Props> = ({
}
let cancelled = false;
const capture = (): void => {
const wv = getWebview(spawnedBrowserId);
const p = wv?.capturePage?.();
if (p && typeof (p as Promise<unknown>).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.
@@ -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<unknown>).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);
},
+23
View File
@@ -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<string | null> {
try {
const wv = getWebview(browserId);
if (!wv?.capturePage) return null;
const image = await Promise.race([
wv.capturePage(),
new Promise<null>((resolve) => { window.setTimeout(() => resolve(null), timeoutMs); }),
]);
return image && !image.isEmpty() ? image.toDataURL() : null;
} catch {
return null;
}
}