diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index 31a89350..a9ed83cb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -39,19 +39,21 @@ function cardIntersectsViewport(card: BrowserCardPosition, vp: Viewport, marginP return card.x < vx + vw && card.x + card.width > vx && card.y < vy + vh && card.y + card.height > vy; } +function sessionIsWorking(s: { status?: string } | undefined): boolean { + return !!s && (s.status === 'running' || s.status === 'waiting_approval'); +} + function agentNeedsLive(browserId: string, card: BrowserCardPosition): boolean { if (getActivity(browserId)) return true; const state = store.getState(); - const glow = state.dashboardLayout.glowingBrowserCards[browserId]; - if (glow && !glow.fading) return true; const sessions = state.agents.sessions as Record; + // A glow holds the card live only while its SOURCE session is still working: a stuck glow (chat unmounted at finish never fades it) must not pin a renderer forever. + const glow = state.dashboardLayout.glowingBrowserCards[browserId]; + if (glow && !glow.fading && sessionIsWorking(sessions[glow.sourceId])) return true; for (const s of Object.values(sessions)) { - if (s.browser_id === browserId && (s.status === 'running' || s.status === 'waiting_approval')) return true; - } - if (card.spawned_by) { - const parent = sessions[card.spawned_by]; - if (parent && (parent.status === 'running' || parent.status === 'waiting_approval')) return true; + if (s.browser_id === browserId && sessionIsWorking(s)) return true; } + if (card.spawned_by && sessionIsWorking(sessions[card.spawned_by])) return true; return false; } @@ -128,9 +130,10 @@ export function useWebviewSuspend( if (isSuspended(id)) continue; if (cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX)) continue; if (mustStayLive(id, card)) continue; + // An empty dataUrl still suspends (placeholder renders): a card whose capture hangs/fails must not keep its renderer alive forever. const dataUrl = await captureCard(id, card); // The capture await yielded; conditions may have changed under us. - if (!dataUrl || cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX) || mustStayLive(id, card)) continue; + if (cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX) || mustStayLive(id, card)) continue; dispatch(suspendBrowserCard({ browserId: id, dataUrl })); } @@ -142,7 +145,7 @@ export function useWebviewSuspend( for (const [id, card] of candidates) { if (countLive() <= MAX_LIVE_WEBVIEWS) break; const dataUrl = await captureCard(id, card); - if (!dataUrl || mustStayLive(id, card)) continue; + if (mustStayLive(id, card)) continue; dispatch(suspendBrowserCard({ browserId: id, dataUrl })); } } @@ -160,19 +163,25 @@ function distFromCenter(card: BrowserCardPosition, vp: Viewport): number { return dx * dx + dy * dy; } -async function captureCard(id: string, card: BrowserCardPosition): Promise { +// capturePage on an already-off-screen webview can HANG forever (Electron 42/Viz stops producing frames for unpainted guests), and one hung await used to wedge the whole suspend pass, silently disabling suspension for every card. Bound it hard. +const CAPTURE_TIMEOUT_MS = 1500; + +async function captureCard(id: string, card: BrowserCardPosition): Promise { const wv = getWebview(id, card.activeTabId); - if (!wv) return null; + if (!wv) return ''; try { - if (wv.isLoading()) return null; + if (wv.isLoading()) return ''; const url = wv.getURL(); - if (!url || url === 'about:blank') return null; - const image = await wv.capturePage(); - if (image.isEmpty()) return null; + if (!url || url === 'about:blank') return ''; + const image = await Promise.race([ + wv.capturePage(), + new Promise((resolve) => setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS)), + ]); + if (!image || image.isEmpty()) return ''; return image.getSize().width > SNAPSHOT_MAX_W ? image.resize({ width: SNAPSHOT_MAX_W, quality: 'good' }).toDataURL() : image.toDataURL(); } catch { - return null; + return ''; } } diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index b6d2f7c4..24c0a591 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -28,7 +28,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { fetchSettings } from '../state/settingsSlice'; import { displaySessionName } from '../state/sessionDisplay'; @@ -384,6 +384,14 @@ class WebSocketManager { store.dispatch(trackAgentNotification(session_id)); } + // Fade this session's browser glows on the terminal transition HERE, not only in AgentChat's effect: a collapsed chat is unmounted at finish, and a never-faded glow pins the browser's renderer (exempt from suspend + the webview cap) forever. + const newStatus = data.status ?? data.session?.status; + const wasWorking = prevStatus === 'running' || prevStatus === 'waiting_approval'; + if (session_id && wasWorking && (newStatus === 'completed' || newStatus === 'stopped' || newStatus === 'error')) { + store.dispatch(fadeGlowingBrowserCards(session_id)); + setTimeout(() => store.dispatch(clearGlowingBrowserCards(session_id)), 600); + } + // Fire a native notification when an agent terminates while the window is hidden. Skips sub-agents and browser-agents (the parent's own completion is what the user cares about) and only fires on a real transition from a non-terminal state. const TERMINAL = new Set(['completed', 'error']); const NON_TERMINAL = new Set(['running', 'waiting_approval', undefined, null, '']);