diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index e475fd66..9bdd382f 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -258,13 +258,18 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const hasDockedBrowser = useAppSelector((st) => Object.values(st.dashboardLayout.browserCards).some((bc) => bc.docked_to === (sessionIdProp || routeId)) || Object.values(st.dashboardLayout.viewCards).some((vc) => vc.docked_to === (sessionIdProp || routeId))); - // The docked surface's aspect ratio, so the inline slot hugs the browser's shape instead of reserving a fixed letterbox band (primitive selectors so no fresh-object rerenders). - const dockedSurfaceW = useAppSelector((st) => - Object.values(st.dashboardLayout.browserCards).find((bc) => bc.docked_to === (sessionIdProp || routeId))?.width ?? 0); - const dockedSurfaceH = useAppSelector((st) => - Object.values(st.dashboardLayout.browserCards).find((bc) => bc.docked_to === (sessionIdProp || routeId))?.height ?? 0); - const dockedSurfaceId = useAppSelector((st) => - Object.values(st.dashboardLayout.browserCards).find((bc) => bc.docked_to === (sessionIdProp || routeId))?.browser_id ?? null); + // The docked surface's aspect ratio, so the inline slot hugs the browser's shape instead of reserving a fixed letterbox band (primitive selectors so no fresh-object rerenders). Highest z wins, mirroring BrowserCard's dock-owner election, so a dead rival's stale dock never feeds dims or shots. + const pickTopDocked = (st: { dashboardLayout: { browserCards: Record } }) => { + let best: { browser_id: string; width: number; height: number; zOrder: number } | null = null; + for (const b of Object.values(st.dashboardLayout.browserCards)) { + if (b.docked_to !== (sessionIdProp || routeId)) continue; + if (!best || (b.zOrder || 0) > (best.zOrder || 0)) best = b; + } + return best; + }; + const dockedSurfaceW = useAppSelector((st) => pickTopDocked(st)?.width ?? 0); + const dockedSurfaceH = useAppSelector((st) => pickTopDocked(st)?.height ?? 0); + const dockedSurfaceId = useAppSelector((st) => pickTopDocked(st)?.browser_id ?? null); // Shared by the anchor slot and the fallback slot so both read as the same framed block. const browserSlotSx = { position: 'relative', diff --git a/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx b/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx index a3c83cfd..f56793bc 100644 --- a/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx @@ -9,8 +9,10 @@ import TetherLayer from './TetherLayer'; // whole dashboard tree (the ENG-88 input delay). const TetherLayerHost: React.FC<{ inputs: TetherInputs; c: ClaudeTokens }> = ({ inputs, c }) => { const [liveDrag, setLiveDrag] = useState(null); - // Tethers only exist while something glows; on an idle canvas a drag frame should cost zero React work, so don't even subscribe. - const hasTethers = Object.keys(inputs.glowingAgentCards).length > 0 || Object.keys(inputs.glowingBrowserCards).length > 0; + // Tethers only exist while something glows AND that something is on the canvas; a DOCKED browser's glow draws no tether (geometry skips docked cards), so dragging its chat must not buy per-frame React either. + const hasTethers = + Object.keys(inputs.glowingAgentCards).length > 0 || + Object.keys(inputs.glowingBrowserCards).some((bid) => !inputs.browserCards[bid]?.docked_to); useEffect(() => { if (!hasTethers) { setLiveDrag(null); return undefined; } return subscribeLiveDrag(setLiveDrag); diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 7da99cfc..4cefdf93 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; @@ -317,6 +318,23 @@ const BrowserCard: React.FC = ({ }, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); const dockParentZ = dockParentCard?.zOrder ?? 0; + // The chat drags on a per-frame compositor transform, but dock geometry only re-measures at settle; ride the same drag channel imperatively or the mini visibly trails its own chat. + const hasDockRect = !!dockRect; + useEffect(() => { + if (!dockedTo || !hasDockRect) return undefined; + const off = subscribeLiveDrag((info) => { + const el = rootElRef.current; + if (!el) return; + if (info && info.cardId === dockedTo) el.style.translate = `${info.dx}px ${info.dy}px`; + else el.style.translate = ''; + }); + return () => { + off(); + const el = rootElRef.current; + if (el) el.style.translate = ''; + }; + }, [dockedTo, hasDockRect]); + const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); @@ -1048,9 +1066,22 @@ const BrowserCard: React.FC = ({ ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : 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; + // Exactly one card owns a chat's dock (highest z wins): a dead browser whose dock the layout sync re-asserts must never stack under its replacement in the same slot. + const dockOwnerId = useAppSelector((state) => { + if (!dockedTo) return null; + let bestId: string | null = null; + let bestZ = -Infinity; + for (const b of Object.values(state.dashboardLayout.browserCards)) { + if (b.docked_to !== dockedTo) continue; + const z = b.zOrder || 0; + if (z > bestZ) { bestZ = z; bestId = b.browser_id; } + } + return bestId; + }); + const isDockOwner = dockOwnerId === browserId; + const dockActive = !!dockRect && isDockOwner && !dragging && !localResize && !isTiled && !keepAliveHidden && !isMinimized; + // Docked in intent but no slot rect yet (slot not mounted, or windowed out before first measure), OR docked but out-elected by a newer dock owner: hide rather than flash the card at its stale canvas home. + const dockPending = !!dockedTo && !!dockParentCard && (!isDockOwner || (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