diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index f7fc5480..8bde7d02 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -233,8 +233,15 @@ const BrowserCard: React.FC = ({ // ---- 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)); + // Ownership survives slot theft (one docked surface per chat steals docked_to): the collapse + // tuck still claims a card this chat SPAWNED unless the user dragged it free (Eric's round-trip repro). + const tuckTo = useAppSelector((state) => { + const bc = state.dashboardLayout.browserCards[browserId]; + if (!bc) return null; + return bc.docked_to ?? ((bc.spawned_by && !bc.freed) ? bc.spawned_by : null); + }); + const dockParentCard = useAppSelector((state) => (tuckTo ? state.dashboardLayout.cards[tuckTo] ?? null : null)); + const dockParentExpanded = useAppSelector((state) => (tuckTo ? state.agents.expandedSessionIds.includes(tuckTo) : 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. @@ -1169,7 +1176,7 @@ const BrowserCard: React.FC = ({ // Chat collapsed: its docked browser parks off-screen and lives on as the pill's frozen shot, // instead of teleporting back to wherever it sat before docking. The park waits for that shot: // an off-screen guest never paints again, and capturePage on one never settles (Electron 42). - const wantsDockPark = !!dockedTo && !!dockParentCard && !dockParentExpanded && !agentDriving && !dragging && !isTiled && !isMinimized && !keepAliveHidden; + const wantsDockPark = !!tuckTo && !!dockParentCard && !dockParentExpanded && !agentDriving && !dragging && !isTiled && !isMinimized && !keepAliveHidden; const dockParked = wantsDockPark && pillShotSettled; // A docked browser's stored x/y is the beside-chat spot captured AT DOCK TIME, so moving the chat // leaves it behind. An agent-driven browser skips the park (above), so it painted itself over @@ -1178,14 +1185,14 @@ const BrowserCard: React.FC = ({ // NO exclusions for dragging/resize/capture-wait: any gap here rendered the FULL-SIZE card // halfway up the pill during transient states (thinking, mid-capture, mid-drag; Eric's shots x3). // Collapsed parent = parked or 320px miniature, at every stage, no third state. - const followsParent = !!dockedTo && !!dockParentCard && !dockParentExpanded && !dockParked + const followsParent = !!tuckTo && !!dockParentCard && !dockParentExpanded && !dockParked && !isTiled && !isMinimized && !keepAliveHidden; // Tell the pill a live miniature is underneath it, so it suppresses its own artifacts instead of // stacking a widget/frozen shot on top of the browser (Eric's overlap screenshots). useEffect(() => { - if (dockedTo) setBrowserFollowing(dockedTo, browserId, followsParent); - return () => { if (dockedTo) setBrowserFollowing(dockedTo, browserId, false); }; - }, [followsParent, dockedTo, browserId]); + if (tuckTo) setBrowserFollowing(tuckTo, browserId, followsParent); + return () => { if (tuckTo) setBrowserFollowing(tuckTo, browserId, false); }; + }, [followsParent, tuckTo, browserId]); // Under the pill, not beside it: beside-at-pill-height read as a detached window fighting the // pill's ring and shadow (Eric, 2026-08-17); tucked below the collapsed pill it reads as the // chat's own attachment, the same visual contract as the docked mini inside an expanded chat. diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index ff15b0e4..f172695c 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -153,8 +153,15 @@ const DashboardViewCard: React.FC = ({ // ---- In-chat dock (mirrors BrowserCard): while docked to an expanded chat, overlay its slot rect. 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)); + // Ownership survives slot theft: a later-docked browser steals docked_to (one slot per chat), + // but the collapse tuck must still claim this card unless the USER dragged it free (ENG-333/324). + const tuckTo = useAppSelector((state) => { + const vc = state.dashboardLayout.viewCards[cardKey]; + if (!vc) return null; + return vc.docked_to ?? ((vc.parent_session_id && !vc.freed) ? vc.parent_session_id : null); + }); + const dockParentCard = useAppSelector((state) => (tuckTo ? state.dashboardLayout.cards[tuckTo] ?? null : null)); + const dockParentExpanded = useAppSelector((state) => (tuckTo ? state.agents.expandedSessionIds.includes(tuckTo) : 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); @@ -633,7 +640,7 @@ const DashboardViewCard: React.FC = ({ const dockActive = !!dockRect && !dragging && !localResize && !isTiled && !isMinimized; // Collapsed parent = the app tucks under the pill as a 320px miniature, EVERY stage, no shadow; // the same contract BrowserCard carries (Eric's screenshots: a full-size app half over the pill). - const followsParent = !!dockedTo && !!dockParentCard && !dockParentExpanded && !isTiled && !isMinimized; + const followsParent = !!tuckTo && !!dockParentCard && !dockParentExpanded && !isTiled && !isMinimized; const followScale = Math.min(1, 320 / displayW); const followX = followsParent && dockParentCard ? dockParentCard.x : null; const followY = followsParent && dockParentCard ? dockParentCard.y + 52 : null; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 4fcb6119..c50d8ab1 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -74,6 +74,8 @@ export interface ViewCardPosition { parent_session_id?: string | null; /** Chat session this app preview lives inside (renders over the chat's dock slot); null/absent = free card. */ docked_to?: string | null; + // Set when the USER drags the card out of its chat; the collapse tuck respects it forever. + freed?: boolean; // Reveal-born apps start as a light "built for you, click to open" card so the onboarding curtain // lifts INSTANTLY instead of booting a live Vite preview in-frame. Cleared on first click -> boots. preview_deferred?: boolean; @@ -108,6 +110,8 @@ export interface BrowserCardPosition { dashboard_id?: string; /** Chat session this browser lives inside (renders over the chat's dock slot); null/absent = free card. */ docked_to?: string | null; + // Set when the USER drags the card out of its chat; the collapse tuck respects it forever. + freed?: boolean; } export interface WorkflowCardPosition { @@ -1100,12 +1104,14 @@ const dashboardLayoutSlice = createSlice({ if (!vc) return; if (action.payload.dockedTo) clearOtherDocks(state, action.payload.dockedTo); vc.docked_to = action.payload.dockedTo; + vc.freed = action.payload.dockedTo === null; }, setBrowserDocked(state, action: PayloadAction<{ browserId: string; dockedTo: string | null }>) { const bc = state.browserCards[action.payload.browserId]; if (!bc) return; if (action.payload.dockedTo) clearOtherDocks(state, action.payload.dockedTo, action.payload.browserId); bc.docked_to = action.payload.dockedTo; + bc.freed = action.payload.dockedTo === null; }, addBrowserCardFromBackend(state, action: PayloadAction) { const card = action.payload;