diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 78c2c734..457139ea 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -26,6 +26,7 @@ import { setBrowserCardSize, removeBrowserCard, resumeBrowserCard, + cancelBrowserCardEnding, addBrowserTab, removeBrowserTab, setActiveBrowserTab, @@ -219,6 +220,7 @@ const BrowserCard: React.FC = ({ const browserAgentSession = useAppSelector(selectBrowserAgentSession); const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); + const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); // Arm the Windows webview crash-safety marker synchronously, before React commits // the below. Cleared on dom-ready; a leftover marker next launch tells @@ -236,6 +238,7 @@ const BrowserCard: React.FC = ({ const [tabLocalStates, setTabLocalStates] = useState>({}); // Electron webviews can't trigger OS platform auth; preload sends "passkey-detected" and we explain via modal. const [passkeyDialogOpen, setPasskeyDialogOpen] = useState(false); + const [crashedTabs, setCrashedTabs] = useState>(new Set()); const updateTabLocal = useCallback((tabId: string, update: Partial) => { setTabLocalStates((prev) => { const existing = prev[tabId] ?? { loading: false, canGoBack: false, canGoForward: false }; @@ -269,6 +272,17 @@ const BrowserCard: React.FC = ({ if (suspendedSnap) initializedTabs.current.clear(); }, [suspendedSnap]); + // Spawned cards get marked "ending" by WebSocketManager when the parent agent + // finishes; show the fade pill for ~3s, then dispatch the real remove. Keep + // clears the flag and the cleanup below cancels the pending remove. + useEffect(() => { + if (!endingState) return; + const timer = setTimeout(() => { + dispatch(removeBrowserCard(browserId)); + }, 3000); + return () => clearTimeout(timer); + }, [endingState, browserId, dispatch]); + const tabIdKey = tabs.map((t) => t.id).join(','); useEffect(() => { if (!isElectron) return; @@ -342,6 +356,20 @@ const BrowserCard: React.FC = ({ updateTabLocal(tabId, { loading: false }); onNavigate(); onTitleUpdate(); + setCrashedTabs((prev) => { + if (!prev.has(tabId)) return prev; + const next = new Set(prev); + next.delete(tabId); + return next; + }); + }; + const onProcessGone = () => { + setCrashedTabs((prev) => { + if (prev.has(tabId)) return prev; + const next = new Set(prev); + next.add(tabId); + return next; + }); }; const onFaviconUpdate = (e: any) => { @@ -366,6 +394,8 @@ const BrowserCard: React.FC = ({ wv.addEventListener('page-favicon-updated', onFaviconUpdate); wv.addEventListener('ipc-message', onIpcMessage as any); wv.addEventListener('new-window', onNewWindow as any); + wv.addEventListener('render-process-gone', onProcessGone as any); + wv.addEventListener('crashed', onProcessGone as any); cleanups.push(() => { unregisterWebview(browserId, tabId); @@ -377,6 +407,8 @@ const BrowserCard: React.FC = ({ wv.removeEventListener('page-favicon-updated', onFaviconUpdate); wv.removeEventListener('ipc-message', onIpcMessage as any); wv.removeEventListener('new-window', onNewWindow as any); + wv.removeEventListener('render-process-gone', onProcessGone as any); + wv.removeEventListener('crashed', onProcessGone as any); }); } @@ -1119,31 +1151,104 @@ const BrowserCard: React.FC = ({ ) ) : ( - tabs.map((tab) => ( - { - if (el) webviewMap.current.set(tab.id, el as unknown as WebviewElement); - else webviewMap.current.delete(tab.id); - }} - data-tab-id={tab.id} - src="about:blank" - {...({ allowpopups: 'true' } as any) /* React drops boolean-valued unknown attrs, so string it stays; @types/react wrongly says boolean */} - useragent={chromeUserAgent} - {...(webviewPreloadPath ? { preload: webviewPreloadPath } : {})} - webpreferences="plugins=yes, autoplayPolicy=no-user-gesture-required, backgroundThrottling=no" /* throttling: guests get occlusion-suspended on their own even with the host's disable-renderer-backgrounding, freezing agent JS when the window is covered */ - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - border: 'none', - visibility: tab.id === activeTabId ? 'visible' : 'hidden', - zIndex: tab.id === activeTabId ? 1 : 0, - }} - /> - )) + <> + {tabs.map((tab) => ( + { + if (el) webviewMap.current.set(tab.id, el as unknown as WebviewElement); + else webviewMap.current.delete(tab.id); + }} + data-tab-id={tab.id} + src="about:blank" + {...({ allowpopups: 'true' } as any) /* React drops boolean-valued unknown attrs, so string it stays; @types/react wrongly says boolean */} + useragent={chromeUserAgent} + {...(webviewPreloadPath ? { preload: webviewPreloadPath } : {})} + webpreferences="plugins=yes, autoplayPolicy=no-user-gesture-required, backgroundThrottling=no" /* throttling: guests get occlusion-suspended on their own even with the host's disable-renderer-backgrounding, freezing agent JS when the window is covered */ + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + border: 'none', + visibility: tab.id === activeTabId ? 'visible' : 'hidden', + zIndex: tab.id === activeTabId ? 1 : 0, + }} + /> + ))} + + + + {endingState?.status === 'error' ? 'Task ended with an error.' : 'Task done.'} + + + + + + + + This page stopped responding. + + + + + ) ) : null} ; + /** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */ + endingBrowserCards: Record; } const initialState: DashboardLayoutState = { @@ -113,6 +115,7 @@ const initialState: DashboardLayoutState = { pendingFocusBrowserId: null, pendingFocusNoteId: null, suspendedBrowserCards: {}, + endingBrowserCards: {}, }; interface LayoutPayload { @@ -635,6 +638,21 @@ const dashboardLayoutSlice = createSlice({ removeBrowserCard(state, action: PayloadAction) { delete state.browserCards[action.payload]; delete state.suspendedBrowserCards[action.payload]; + delete state.endingBrowserCards[action.payload]; + }, + + markBrowserCardEnding( + state, action: PayloadAction<{ browserId: string; status: 'completed' | 'error' }>, + ) { + if (!state.browserCards[action.payload.browserId]) return; + state.endingBrowserCards[action.payload.browserId] = { + status: action.payload.status, + at: Date.now(), + }; + }, + + cancelBrowserCardEnding(state, action: PayloadAction) { + delete state.endingBrowserCards[action.payload]; }, suspendBrowserCard(state, action: PayloadAction<{ browserId: string; dataUrl: string }>) { @@ -959,6 +977,7 @@ const dashboardLayoutSlice = createSlice({ state.initialized = false; state.pendingFocusNoteId = null; state.suspendedBrowserCards = {}; + state.endingBrowserCards = {}; }, }, @@ -1068,6 +1087,8 @@ export const { removeBrowserCard, suspendBrowserCard, resumeBrowserCard, + markBrowserCardEnding, + cancelBrowserCardEnding, pasteBrowserCard, updateBrowserCardUrl, addBrowserTab, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index cd228599..f18ba8bf 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { getAuthToken } from '../config'; import { notifyAgentCompletion } from '../notifications'; @@ -500,6 +500,8 @@ class WebSocketManager { // sub-agent: the parent reuses the same browser_id for its next step, // so deleting on sub-agent completion strands BrowserAgent(browser_id) // on a dead card. 'stopped' skipped to allow inspect-after-manual-stop. + // Mark the card as ending instead of removing immediately so the card + // shows a fade + Keep pill; BrowserCard owns the 3s timer to remove. if ( session_id && (data.status === 'completed' || data.status === 'error') && @@ -508,7 +510,9 @@ class WebSocketManager { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { if (card.spawned_by === session_id) { - store.dispatch(removeBrowserCard(card.browser_id)); + store.dispatch(markBrowserCardEnding({ + browserId: card.browser_id, status: data.status, + })); } } } @@ -723,11 +727,15 @@ class WebSocketManager { // Auto-delete browsers spawned by this agent when it finishes // normally or errors out. We intentionally skip 'stopped' , the // user may want to inspect the browser after manually stopping. + // Mark for removal so BrowserCard renders a fade + Keep pill; + // the card itself runs the 3s timer to dispatch removeBrowserCard. if (closedStatus === 'completed' || closedStatus === 'error') { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { if (card.spawned_by === session_id) { - store.dispatch(removeBrowserCard(card.browser_id)); + store.dispatch(markBrowserCardEnding({ + browserId: card.browser_id, status: closedStatus, + })); } } }