From aeb2b3fb144811fc914c085d6a591344cf8ce557 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 15:55:40 -0700 Subject: [PATCH] [eric] dashboard: fullscreen chat docks the real browser (slot in full view, posEl-aware tile applier w/ live camera tracking, dock re-measure on slot moves, no dupe embed) --- .../src/app/pages/AgentChat/AgentChat.tsx | 2 +- .../AgentChat/shell/InlineSurfaceEmbeds.tsx | 4 +- .../app/pages/Dashboard/cards/BrowserCard.tsx | 16 +++++- .../Dashboard/cards/DashboardViewCard.tsx | 16 +++++- .../app/pages/Dashboard/cards/tileZones.ts | 56 ++++++++++++++++--- 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 87d9768a..229b10d5 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -2402,7 +2402,7 @@ 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 && ( + {hasDockedBrowser && ( s.parent_session_id === sessionId && s.browser_id) .map((s) => s.browser_id as string), ); + // A docked browser renders its REAL card inside the chat (even in full size view), so the + // snapshot embed would be a stale duplicate right next to the live thing. return Object.values(browserCards).filter( - (bc) => bc.spawned_by === sessionId || childBrowserIds.has(bc.browser_id), + (bc) => !bc.docked_to && (bc.spawned_by === sessionId || childBrowserIds.has(bc.browser_id)), ); }, [browserCards, sessions, sessionId]); const linkedApps = useMemo( diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 6155b559..3aae94fd 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -247,6 +247,7 @@ const BrowserCard: React.FC = ({ 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 dockParentTiled = useAppSelector((state) => (dockedTo ? state.dashboardLayout.tiledCards[dockedTo] : undefined)); const [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const rootElRef = useRef(null); useEffect(() => { @@ -267,9 +268,18 @@ const BrowserCard: React.FC = ({ if (slot) ro.observe(slot); if (slot?.parentElement) ro.observe(slot.parentElement); window.addEventListener('resize', measure); - return () => { ro.disconnect(); window.removeEventListener('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. + window.addEventListener('openswarm:canvas-pan-changed', measure); + const timers = [60, 250, 700].map((ms) => window.setTimeout(measure, ms)); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + window.removeEventListener('openswarm:canvas-pan-changed', measure); + 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. - }, [dockedTo, dockParentExpanded, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); + }, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); const dockParentZ = dockParentCard?.zOrder ?? 0; const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); @@ -1032,7 +1042,7 @@ const BrowserCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? dockParentZ + 1 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : 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/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index e00306f7..4db1dbb0 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -173,6 +173,7 @@ const DashboardViewCard: React.FC = ({ const dockedTo = useAppSelector((state) => state.dashboardLayout.viewCards[cardKey]?.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 dockParentTiled = useAppSelector((state) => (dockedTo ? state.dashboardLayout.tiledCards[dockedTo] : undefined)); const [dockRect, setDockRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const dockRootRef = useRef(null); useEffect(() => { @@ -192,8 +193,17 @@ const DashboardViewCard: React.FC = ({ if (slot) ro.observe(slot); if (slot?.parentElement) ro.observe(slot.parentElement); window.addEventListener('resize', measure); - return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; - }, [dockedTo, dockParentExpanded, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); + // 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. + window.addEventListener('openswarm:canvas-pan-changed', measure); + const timers = [60, 250, 700].map((ms) => window.setTimeout(measure, ms)); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + window.removeEventListener('openswarm:canvas-pan-changed', measure); + timers.forEach((tm) => window.clearTimeout(tm)); + }; + }, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); const dockParentZ = dockParentCard?.zOrder ?? 0; // Keep the live preview mounted only when the user can actually see/use this app card. Always live @@ -628,7 +638,7 @@ const DashboardViewCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : 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/app/pages/Dashboard/cards/tileZones.ts b/frontend/src/app/pages/Dashboard/cards/tileZones.ts index 31fdcbb3..95ebd4f2 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileZones.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileZones.ts @@ -108,27 +108,65 @@ export function useTiledStyle( if (!zone || !getLive || !selectId) return undefined; const el = document.querySelector(`[data-select-id="${CSS.escape(selectId)}"]`) as HTMLElement | null; if (!el) return undefined; - const props = ['left', 'top', 'width', 'height', 'transform', 'transform-origin']; + // left/top belong on whichever element OWNS position: AgentCard splits into a position:absolute + // motion wrapper around a position:relative inner Box (writing left/top on the inner Box ADDS to + // the wrapper's, rendering the tile at exactly double the offset); Browser/View cards are one + // element. Size + counter-scale always live on the select-id element, mirroring the sx path. + const posEl = getComputedStyle(el).position === 'absolute' || getComputedStyle(el).position === 'fixed' + ? el + : (el.parentElement as HTMLElement | null) ?? el; + const posProps = ['left', 'top']; + const sizeProps = ['width', 'height', 'transform', 'transform-origin']; + const clearAll = (): void => { + posProps.forEach((pr) => posEl.style.removeProperty(pr)); + sizeProps.forEach((pr) => el.style.removeProperty(pr)); + }; const apply = (): void => { // A parked card (kept alive off-screen / minimized) must keep its sx parking position. - if (el.getAttribute('data-keepalive-hidden') === '1') { props.forEach((pr) => el.style.removeProperty(pr)); return; } + if (el.getAttribute('data-keepalive-hidden') === '1') { clearAll(); return; } const cam = getLive(); const s = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom); if (!s) return; - el.style.left = `${s.left}px`; - el.style.top = `${s.top}px`; - el.style.width = `${s.width}px`; - el.style.height = `${s.height}px`; - el.style.transform = s.transform; - el.style.transformOrigin = s.transformOrigin; + // 'important' so framer-motion's own left/top writes (computed off the LAGGING React-committed + // camera) can't land after us and shift the tile by the stale-camera delta. + posEl.style.setProperty('left', `${s.left}px`, 'important'); + posEl.style.setProperty('top', `${s.top}px`, 'important'); + el.style.setProperty('width', `${s.width}px`, 'important'); + el.style.setProperty('height', `${s.height}px`, 'important'); + el.style.setProperty('transform', s.transform, 'important'); + el.style.setProperty('transform-origin', s.transformOrigin, 'important'); }; apply(); window.addEventListener('openswarm:canvas-pan-changed', apply); window.addEventListener('resize', apply); + // The workspace can change size WITHOUT a window resize (chrome collapsing on fullscreen-enter, + // a banner appearing). These inline writes OVERRIDE the class styles, so they must re-measure on + // the same signals the React path uses or the tile keeps the pre-collapse viewport (the + // fullscreen card that stopped short of the bottom edge). + const vp = document.querySelector('[data-canvas-viewport]'); + const ro = new ResizeObserver(apply); + if (vp) ro.observe(vp); + const timers = [60, 250, 700].map((ms) => window.setTimeout(apply, ms)); + // animateTo moves the camera WITHOUT emitting pan-changed, and the important-priority writes + // above block React's late self-heal, so a tiled card must track the live camera itself: + // a write-on-change rAF loop (frozen while hidden is fine; nothing moves visually then). + let raf = 0; + let lastKey = ''; + const tick = (): void => { + const cam = getLive(); + const size = workspaceSize(); + const key = `${cam.panX}:${cam.panY}:${cam.zoom}:${size.w}:${size.h}`; + if (key !== lastKey) { lastKey = key; apply(); } + raf = window.requestAnimationFrame(tick); + }; + raf = window.requestAnimationFrame(tick); return () => { window.removeEventListener('openswarm:canvas-pan-changed', apply); window.removeEventListener('resize', apply); - props.forEach((pr) => el.style.removeProperty(pr)); + ro.disconnect(); + timers.forEach((tm) => window.clearTimeout(tm)); + window.cancelAnimationFrame(raf); + clearAll(); }; }, [zone, getLive, selectId]); return zone ? computeTiledStyle(zone, panX, panY, zoom) : null;