From cab51313e652c3d8779e7736e77abb5e8d7c80bd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 19:24:10 -0700 Subject: [PATCH] [eric] chat: the live browser anchors at its browser tool row, not the transcript tail, and never pops to canvas when its row windows out --- .../src/app/pages/AgentChat/AgentChat.tsx | 29 +++++++++++++++++-- .../app/pages/Dashboard/cards/BrowserCard.tsx | 9 ++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 666984eb..fbafc082 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1248,6 +1248,19 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return items; }, [activeBranchMessages, sessionRunning]); + // The docked browser anchors at the LAST browser-agent tool row (one live browser, latest work site wins); no row yet falls back to the end-of-transcript slot. + const browserAnchorItemId = useMemo((): string | null => { + const isBrowserCall = (m: AgentMessage): boolean => + m.role === 'tool_call' && typeof m.content === 'object' && String((m.content as { tool?: string })?.tool || '').toLowerCase().endsWith('browseragent'); + let anchor: string | null = null; + for (const item of renderItems) { + if (isToolGroup(item)) { if (item.pairs.some((p) => isBrowserCall(p.call))) anchor = item.id; } + else if (isToolPair(item)) { if (isBrowserCall(item.call)) anchor = item.id; } + else if (isBrowserCall(item as AgentMessage)) anchor = item.id; + } + return anchor; + }, [renderItems]); + React.useLayoutEffect(() => { const total = renderItems.length; renderItemsLengthRef.current = total; @@ -1701,6 +1714,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} {renderedVisibleItems.map((item, itemIdx) => { + const rendered = ((): React.ReactNode => { const isLastVisibleItem = itemIdx === renderedVisibleItems.length - 1; const isCompactionAnchor = !!session.compacted_through_msg_id && item.id === session.compacted_through_msg_id; const compactionChip = isCompactionAnchor ? ( @@ -1841,6 +1855,17 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); + })(); + // The live browser anchors AT the browser tool row (ChatGPT-agent model): the view sits where the work happened, above the answer that follows it. + if (hasDockedBrowser && browserAnchorItemId && item.id === browserAnchorItemId) { + return ( + + {rendered} + + + ); + } + return rendered; })} {/* Stand-in for items unmounted BELOW the window (newer items not yet scrolled into view). Zero while following the live tail. */} @@ -1987,8 +2012,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} - {/* Inline dock slot: the agent's browser rides HERE, in the transcript flow like a tool output (the real card overlays this rect geometrically, so the webview never remounts). It scrolls with the conversation; the mini hides itself when this scrolls mostly out of view, since a live webview can't be clipped by the scroller. */} - {hasDockedBrowser && ( + {/* Fallback dock slot for a browser that docked before any browser tool row exists (or whose row was compacted away); once a row appears the slot anchors at it instead (see browserAnchorItemId). The real card overlays this rect geometrically, so the webview never remounts; the mini hides itself when its slot scrolls mostly out of view, since a live webview can't be clipped by the scroller. */} + {hasDockedBrowser && !browserAnchorItemId && ( = ({ const measure = (): void => { const slot = document.querySelector(`[data-browser-slot="${dockedTo}"]`); const layer = rootElRef.current?.parentElement; - if (!slot || !layer) { setDockRect(null); return; } + // The slot lives in the transcript's virtualized window, so it can UNMOUNT while scrolled far away; keep the last rect and hide, because nulling it here popped the card back to its stale canvas home. + if (!slot || !layer) { setDockVisible(false); return; } // The slot mounts a beat after docking (and remounts with chat re-renders), so observers hook the live node whenever it changes; a one-shot hookup at effect time reliably missed it and froze the rect. if (slot !== hookedSlot) { ro.disconnect(); @@ -1030,6 +1031,8 @@ const BrowserCard: React.FC = ({ : c.shadow.md; const dockActive = !!dockRect && !dragging && !localResize && !isTiled && !keepAliveHidden && !isMinimized; + // Docked in intent but no slot rect yet (slot not mounted, or windowed out before first measure): hide rather than flash the card at its stale canvas home. + const dockPending = !!dockedTo && !!dockParentCard && dockParentExpanded && !dockRect && !dragging && !isTiled && !isMinimized && !keepAliveHidden; // An agent can only SEE a page the compositor is drawing, and Chromium draws nothing at all for a // guest parked at left:-100000. Measured in one window: a card on screen captured in 58ms while // the same card parked timed out on guest capturePage, on host capturePage AND on CDP @@ -1114,7 +1117,7 @@ const BrowserCard: React.FC = ({ sx={{ position: 'absolute', // Kept-alive card from another dashboard: parked far off-screen so its webview surface can't bleed onto the dashboard you're viewing; click-through, webContents stays mounted. A dock-hidden mini (slot scrolled away) is click-through too. - pointerEvents: keepAliveHidden || isMinimized || dockParked || (dockActive && !dockVisible) ? 'none' : undefined, + pointerEvents: keepAliveHidden || isMinimized || dockParked || dockPending || (dockActive && !dockVisible) ? 'none' : undefined, // contain: webview repaints don't shake neighbor cards. contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. @@ -1137,7 +1140,7 @@ const BrowserCard: React.FC = ({ flexDirection: 'column', zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, // The inline slot scrolls with the transcript; a webview can't be clipped by the scroller, so the mini fades out when its slot is mostly out of view instead of floating over unrelated messages. - opacity: dockActive && !dockVisible ? 0 : 1, + opacity: (dockActive && !dockVisible) || dockPending ? 0 : 1, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease, opacity 0.14s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && {