From bf4faa786f25bd2589740bea7da2ca46d167da6e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 16:57:34 -0700 Subject: [PATCH] [eric] chat: the agent's browser rides the transcript inline like a tool output, the pinned bottom slot is gone --- .../src/app/pages/AgentChat/AgentChat.tsx | 28 ++++--------- .../app/pages/Dashboard/cards/BrowserCard.tsx | 40 +++++++++++++++---- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 299a6c87..666984eb 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1987,6 +1987,13 @@ 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 && ( + + )} {showScrollButton && ( @@ -2418,27 +2425,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); })()} - {/* Dock slot: a browser this agent spawned lives HERE by default (the real card overlays - this rect geometrically, so the webview never remounts). Pinned between transcript - and composer, never inside the scroller, so it can't be clipped by chat scroll. */} - {hasDockedBrowser && ( - - )} {readOnly ? null : isStoppableSidecar ? ( ) : ( diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 0b24b62a..df41113e 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -247,24 +247,44 @@ const BrowserCard: React.FC = ({ const dockParentExpanded = useAppSelector((state) => (dockedTo ? state.agents.expandedSessionIds.includes(dockedTo) : false)); const dockParentTiled = useAppSelector((state) => (dockedTo ? state.dashboardLayout.tiledCards[dockedTo] : undefined)); const [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + // The slot now lives INSIDE the transcript scroller (inline like a tool output), and a live webview cannot be clipped by a scroll container, so the mini hides when its slot scrolls mostly out of view instead. + const [dockVisible, setDockVisible] = useState(true); const rootElRef = useRef(null); useEffect(() => { - if (!dockedTo || !dockParentCard || !dockParentExpanded) { setDockRect(null); return undefined; } + if (!dockedTo || !dockParentCard || !dockParentExpanded) { setDockRect(null); setDockVisible(true); return undefined; } + let scrollHost: Element | null = null; + let hookedSlot: Element | null = null; + let scrollRaf = 0; + const onScroll = (): void => { if (!scrollRaf) scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; measure(); }); }; + const ro = new ResizeObserver(() => measure()); const measure = (): void => { const slot = document.querySelector(`[data-browser-slot="${dockedTo}"]`); const layer = rootElRef.current?.parentElement; if (!slot || !layer) { setDockRect(null); 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(); + ro.observe(slot); + if (slot.parentElement) ro.observe(slot.parentElement); + scrollHost?.removeEventListener('scroll', onScroll); + scrollHost = slot.closest('[data-chat-transcript]'); + scrollHost?.addEventListener('scroll', onScroll, { passive: true }); + hookedSlot = slot; + } const z = getCanvasState().zoom || 1; const lr = layer.getBoundingClientRect(); const sr = slot.getBoundingClientRect(); // Slot and card share the transformed layer, so layer-relative coords are camera-invariant. setDockRect({ x: (sr.left - lr.left) / z, y: (sr.top - lr.top) / z, w: sr.width / z, h: sr.height / z }); + if (scrollHost) { + const cr = scrollHost.getBoundingClientRect(); + const overlap = Math.min(sr.bottom, cr.bottom) - Math.max(sr.top, cr.top); + setDockVisible(overlap / Math.max(1, sr.height) >= 0.35); + } else { + setDockVisible(true); + } }; measure(); - const slot = document.querySelector(`[data-browser-slot="${dockedTo}"]`); - const ro = new ResizeObserver(measure); - if (slot) ro.observe(slot); - if (slot?.parentElement) ro.observe(slot.parentElement); window.addEventListener('resize', measure); // A RO only fires on slot RESIZE; the chat tiling/untiling MOVES the slot without resizing the // window, so re-measure on camera writes + settle timers or the docked card lags behind. @@ -276,6 +296,8 @@ const BrowserCard: React.FC = ({ window.removeEventListener('resize', measure); window.removeEventListener('openswarm:canvas-pan-changed', measure); document.removeEventListener('visibilitychange', measure); + scrollHost?.removeEventListener('scroll', onScroll); + if (scrollRaf) cancelAnimationFrame(scrollRaf); timers.forEach((tm) => window.clearTimeout(tm)); }; // dockParentCard x/y/w/h are re-measure triggers: the slot's client rect moves with the chat card. @@ -1091,8 +1113,8 @@ 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. - pointerEvents: keepAliveHidden || isMinimized || dockParked ? 'none' : undefined, + // 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, // 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. @@ -1114,7 +1136,9 @@ const BrowserCard: React.FC = ({ display: 'flex', flexDirection: 'column', zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, - transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', + // 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, + transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease, opacity 0.14s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { animation: 'card-highlight-pulse 2s ease-out forwards',