[eric] canvas: the collapse tuck keys on OWNERSHIP, not slot possession; a later dock stealing the slot no longer strands the first card full-size, and only a user drag-out frees a card for good

This commit is contained in:
ciregenz
2026-08-18 13:26:52 -07:00
parent fccbea46e7
commit 9df2c56e70
3 changed files with 30 additions and 10 deletions
@@ -233,8 +233,15 @@ const BrowserCard: React.FC<Props> = ({
// ---- 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<Props> = ({
// 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<Props> = ({
// 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.
@@ -153,8 +153,15 @@ const DashboardViewCard: React.FC<Props> = ({
// ---- 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<HTMLDivElement | null>(null);
@@ -633,7 +640,7 @@ const DashboardViewCard: React.FC<Props> = ({
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;
@@ -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<BrowserCardPosition>) {
const card = action.payload;