[aidan] bug: browser card fade + keep on agent end

This commit is contained in:
abccodes
2026-06-13 00:32:03 -07:00
parent 61f4eb6504
commit 00ee55ba6b
3 changed files with 162 additions and 28 deletions
@@ -26,6 +26,7 @@ import {
setBrowserCardSize,
removeBrowserCard,
resumeBrowserCard,
cancelBrowserCardEnding,
addBrowserTab,
removeBrowserTab,
setActiveBrowserTab,
@@ -219,6 +220,7 @@ const BrowserCard: React.FC<Props> = ({
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 <webview> below. Cleared on dom-ready; a leftover marker next launch tells
@@ -236,6 +238,7 @@ const BrowserCard: React.FC<Props> = ({
const [tabLocalStates, setTabLocalStates] = useState<Record<string, TabLocalState>>({});
// 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<Set<string>>(new Set());
const updateTabLocal = useCallback((tabId: string, update: Partial<TabLocalState>) => {
setTabLocalStates((prev) => {
const existing = prev[tabId] ?? { loading: false, canGoBack: false, canGoForward: false };
@@ -269,6 +272,17 @@ const BrowserCard: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
</Box>
)
) : (
tabs.map((tab) => (
<webview
key={tab.id}
ref={(el: any) => {
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) => (
<webview
key={tab.id}
ref={(el: any) => {
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,
}}
/>
))}
<Fade in={!!endingState && !crashedTabs.has(activeTabId)} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.25,
bgcolor: c.bg.surface,
}}
>
<Typography sx={{ color: c.text.primary, fontSize: '0.95rem', fontWeight: 500 }}>
{endingState?.status === 'error' ? 'Task ended with an error.' : 'Task done.'}
</Typography>
<Button
onClick={() => dispatch(cancelBrowserCardEnding(browserId))}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: `${c.radius.md}px`,
px: 2.25,
py: 0.6,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
}}
>
Keep
</Button>
</Box>
</Fade>
<Fade in={crashedTabs.has(activeTabId)} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.25,
bgcolor: c.bg.surface,
}}
>
<Typography sx={{ color: c.text.primary, fontSize: '0.95rem', fontWeight: 500 }}>
This page stopped responding.
</Typography>
<Button
onClick={() => webviewMap.current.get(activeTabId)?.reload()}
startIcon={<RefreshIcon sx={{ fontSize: '1rem' }} />}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: `${c.radius.md}px`,
px: 2.25,
py: 0.6,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
}}
>
Reload
</Button>
</Box>
</Fade>
</>
)
) : null}
<Dialog
@@ -96,6 +96,8 @@ export interface DashboardLayoutState {
pendingFocusNoteId: string | null;
/** Transient: snapshot stand-ins for off-screen webviews; never rides the layout PUT. */
suspendedBrowserCards: Record<string, { dataUrl: string; capturedAt: number }>;
/** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */
endingBrowserCards: Record<string, { status: 'completed' | 'error'; at: number }>;
}
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<string>) {
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<string>) {
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,
+11 -3
View File
@@ -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,
}));
}
}
}