From d97dd9891127d921febce4980a0805c578e55ee2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 12:06:15 -0700 Subject: [PATCH] [eric] browser: agent browsers live INSIDE the chat by default (geometric dock slot, webview never remounts); drag out to free it, drop on a chat to dock --- backend/apps/dashboards/models.py | 2 + .../src/app/pages/AgentChat/AgentChat.tsx | 19 ++++++ .../app/pages/Dashboard/cards/BrowserCard.tsx | 65 ++++++++++++++++--- .../src/shared/state/dashboardLayoutSlice.ts | 7 ++ frontend/src/shared/ws/WebSocketManager.ts | 5 +- 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 2027648f..9371f12f 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -44,6 +44,8 @@ class BrowserCardPosition(BaseModel): keep_open: bool = False # The dashboard this card calls home. Persisted so the home survives a save; without it the card reloads untagged and renders on EVERY dashboard (the cross-dashboard bleed). dashboard_id: Optional[str] = None + # Chat session this browser lives inside (renders over the chat's dock slot); None = free card. + docked_to: Optional[str] = None class NotePosition(BaseModel): diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 92c828cc..a4168b84 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -257,6 +257,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }; const { id: routeId } = useParams<{ id: string }>(); const id = sessionIdProp || routeId; + // True while a spawned browser calls this chat home; gates the dock slot the real card overlays. + const hasDockedBrowser = useAppSelector((st) => Object.values(st.dashboardLayout.browserCards).some((bc) => bc.docked_to === (sessionIdProp || routeId))); // A card linked as a workflow sidecar (Test Agent, or a watched run) swaps its composer for a Force Stop button: continuing the chat is meaningless, but killing the run is the common need. Once a Test Agent finishes, the button flips to a green "close" (see workflow_test_state + ForceStopAgentBar). const linkedSidecar = useAppSelector((s) => { const found = Object.values(s.workflows.openCards).find( @@ -2395,6 +2397,23 @@ 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 && !fullscreenChat && ( + + )} {readOnly ? null : isStoppableSidecar ? ( ) : ( diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 3c536939..35a7204f 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -40,6 +40,7 @@ import { toggleMinimizeCard, setTiledCard, clearTiledCard, + setBrowserDocked, type BrowserTab, } from '@/shared/state/dashboardLayoutSlice'; import WindowControls from './WindowControls'; @@ -239,6 +240,36 @@ const BrowserCard: React.FC = ({ else dispatch(setTiledCard({ cardId: browserId, zone })); }, [dispatch, browserId]); + // ---- In-chat dock: while docked to an expanded chat, the card overlays the chat's slot rect. + // Pure geometry in the shared canvas layer (same DOM node), so the webview never remounts. + const dockedTo = useAppSelector((state) => state.dashboardLayout.browserCards[browserId]?.docked_to ?? null); + const dockParentCard = useAppSelector((state) => (dockedTo ? state.dashboardLayout.cards[dockedTo] ?? null : null)); + const dockParentExpanded = useAppSelector((state) => (dockedTo ? state.agents.expandedSessionIds.includes(dockedTo) : false)); + const [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const rootElRef = useRef(null); + useEffect(() => { + if (!dockedTo || !dockParentCard || !dockParentExpanded) { setDockRect(null); return undefined; } + const measure = (): void => { + const slot = document.querySelector(`[data-browser-slot="${dockedTo}"]`); + const layer = rootElRef.current?.parentElement; + if (!slot || !layer) { setDockRect(null); return; } + 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 }); + }; + 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); + return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; + // dockParentCard x/y/w/h are re-measure triggers: the slot's client rect moves with the chat card. + }, [dockedTo, dockParentExpanded, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); + const dockParentZ = dockParentCard?.zOrder ?? 0; + const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); @@ -717,13 +748,13 @@ const BrowserCard: React.FC = ({ e.preventDefault(); e.stopPropagation(); const cs = getCanvasState(); - dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY }; + dragState.current = { startX: e.clientX, startY: e.clientY, origX: dockRect?.x ?? cardX, origY: dockRect?.y ?? cardY, startPanX: cs.panX, startPanY: cs.panY }; lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; didDrag.current = false; setIsDragging(true); - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ } onDragStart?.(browserId, 'browser'); - }, [cardX, cardY, onDragStart, browserId, getCanvasState, tileZone]); + }, [cardX, cardY, onDragStart, browserId, getCanvasState, tileZone, dockRect]); const recomputeDragPos = useCallback(() => { const ds = dragState.current; @@ -777,6 +808,17 @@ const BrowserCard: React.FC = ({ finalX = Math.round(finalX / 24) * 24; finalY = Math.round(finalY / 24) * 24; } + // Dropping over a chat docks the browser INTO it; anywhere else undocks to a free card. + const { clientX: hx, clientY: hy } = lastPointerRef.current; + const under = document.elementsFromPoint(hx, hy); + const slotHit = under.map((el) => (el as HTMLElement).closest?.('[data-browser-slot]') as HTMLElement | null).find(Boolean); + const chatHit = under.map((el) => (el as HTMLElement).closest?.('[data-select-type="agent-card"]') as HTMLElement | null).find(Boolean); + const dockTarget = slotHit?.getAttribute('data-browser-slot') || chatHit?.getAttribute('data-select-id') || null; + if (dockTarget) { + dispatch(setBrowserDocked({ browserId, dockedTo: dockTarget })); + } else if (dockedTo) { + dispatch(setBrowserDocked({ browserId, dockedTo: null })); + } dispatch(setBrowserCardPosition({ browserId, x: finalX, @@ -790,7 +832,7 @@ const BrowserCard: React.FC = ({ didDrag.current = false; setLocalDragPos(null); setIsDragging(false); - }, [dispatch, browserId, onDragEnd, getCanvasState]); + }, [dispatch, browserId, onDragEnd, getCanvasState, dockedTo]); const handleDragPointerUp = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; @@ -928,8 +970,11 @@ const BrowserCard: React.FC = ({ ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.md; + const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !keepAliveHidden && !isMinimized; + return ( = ({ contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. willChange: 'transform', - left: keepAliveHidden || isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : (dragging ? cardX : displayX)), - top: tiledStyle && !(keepAliveHidden || isMinimized) ? tiledStyle.top : (dragging ? cardY : displayY), + left: keepAliveHidden || isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)), + top: tiledStyle && !(keepAliveHidden || isMinimized) ? tiledStyle.top : dockActive ? dockRect!.y : (dragging ? cardY : displayY), transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, - width: tiledStyle ? tiledStyle.width : displayW, - height: tiledStyle ? tiledStyle.height : displayH, - borderRadius: tileZone === 'fullscreen' ? '12px' : `${c.radius.lg}px`, + width: tiledStyle ? tiledStyle.width : dockActive ? dockRect!.w : displayW, + height: tiledStyle ? tiledStyle.height : dockActive ? dockRect!.h : displayH, + borderRadius: tileZone === 'fullscreen' ? '12px' : dockActive ? '10px' : `${c.radius.lg}px`, border: agentBorder, bgcolor: c.bg.surface, boxShadow: agentShadow, overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? dockParentZ + 1 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index dd0b8d6e..43ff5c8c 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -84,6 +84,8 @@ export interface BrowserCardPosition { keep_open?: boolean; /** Dashboard this card belongs to; cards render and persist only on their owning dashboard. */ dashboard_id?: string; + /** Chat session this browser lives inside (renders over the chat's dock slot); null/absent = free card. */ + docked_to?: string | null; } export interface WorkflowCardPosition { @@ -930,6 +932,10 @@ const dashboardLayoutSlice = createSlice({ if (state.viewCards[action.payload]) state.pendingFocusViewCardId = action.payload; }, + setBrowserDocked(state, action: PayloadAction<{ browserId: string; dockedTo: string | null }>) { + const bc = state.browserCards[action.payload.browserId]; + if (bc) bc.docked_to = action.payload.dockedTo; + }, addBrowserCardFromBackend(state, action: PayloadAction) { const card = action.payload; if (state.browserCards[card.browser_id]) return; @@ -1812,6 +1818,7 @@ export const { setActiveViewCardId, addBrowserCard, addBrowserCardFromBackend, + setBrowserDocked, setBrowserCardPosition, setBrowserCardSize, removeBrowserCard, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 320262f0..05891864 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -28,7 +28,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, setBrowserDocked, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { fetchSettings } from '../state/settingsSlice'; import { displaySessionName } from '../state/sessionDisplay'; @@ -847,6 +847,9 @@ class WebSocketManager { let glowLabel = 'Use Browser'; if (parentCard) { pos = placeBrowserBesideChat(layoutState, parentCard, parentId, browserCard.width, browserCard.height, browserCard.browser_id); + // Default home is INSIDE the chat: the card overlays the chat's dock slot while the + // chat is expanded; the beside-chat spot stays the undock/collapse fallback. + store.dispatch(setBrowserDocked({ browserId: browserCard.browser_id, dockedTo: parentId })); } else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) { pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP, true); } else if (sess?.workflow_edit_id && layoutState.workflowsHub) {