[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

This commit is contained in:
ciregenz
2026-08-04 19:24:10 -07:00
parent eefee1f19a
commit cab51313e6
2 changed files with 33 additions and 5 deletions
+27 -2
View File
@@ -1248,6 +1248,19 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
<Box aria-hidden data-window-spacer="top" sx={{ height: topSpacerHeight, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
</Box>
);
})();
// 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 (
<React.Fragment key={`${item.id}-with-browser`}>
{rendered}
<Box data-browser-slot={id} sx={{ height: 'min(360px, 38vh)', minHeight: 180, mt: 1, mb: 0.5 }} />
</React.Fragment>
);
}
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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
</Box>
)}
{/* 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 && (
<Box
data-browser-slot={id}
sx={{ height: 'min(360px, 38vh)', minHeight: 180, mt: 1, mb: 0.5 }}
@@ -260,7 +260,8 @@ const BrowserCard: React.FC<Props> = ({
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<Props> = ({
: 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<Props> = ({
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<Props> = ({
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 && {