From ed9085eae18066741e6bea7d441e08f8d7523b18 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 14:12:26 -0700 Subject: [PATCH] [eric] dock: one surface per chat (new dock releases old), collapse parks the docked browser behind the pill, slot scales with the chat, dead tether gone, dock tiles never letters, dictation copy points at the combo macOS can't steal --- .../src/app/pages/AgentChat/AgentChat.tsx | 2 +- .../app/pages/Dashboard/cards/AgentCard.tsx | 4 ++-- .../app/pages/Dashboard/cards/BrowserCard.tsx | 11 +++++---- .../pages/Dashboard/desktop/dockEntries.tsx | 9 +++---- .../Dashboard/geometry/dashboardTethers.ts | 2 ++ .../sections/general/GeneralInterface.tsx | 2 +- .../src/shared/state/dashboardLayoutSlice.ts | 24 ++++++++++++++++--- 7 files changed, 37 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b12c2d49..a600a792 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -2407,7 +2407,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose data-browser-slot={id} sx={{ flexShrink: 0, - height: 320, + height: 'clamp(240px, 42%, 560px)', mx: 1.5, mb: 1, borderRadius: '10px', diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 755961f7..467b92fe 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -710,10 +710,10 @@ const AgentCard: React.FC = ({ if ((session.messages || []).length === 0) dispatch(fetchSession(session.id)); }, [pillMode, session.messages, session.id, dispatch]); - // f7's collapsed state: a session that spawned a browser shows that window under the pill. + // f7's collapsed state: a session's browser (spawned by it or docked into it) shows under the pill. const spawnedBrowserId = useAppSelector((s) => { for (const bc of Object.values(s.dashboardLayout.browserCards)) { - if (bc.spawned_by === session.id) return bc.browser_id; + if (bc.spawned_by === session.id || bc.docked_to === session.id) return bc.browser_id; } return null; }); diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 6b670bea..6155b559 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -973,6 +973,9 @@ const BrowserCard: React.FC = ({ : c.shadow.md; const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !keepAliveHidden && !isMinimized; + // Chat collapsed: its docked browser parks off-screen and lives on as the pill's live shot, + // instead of teleporting back to wherever it sat before docking. + const dockParked = !!dockedTo && !!dockParentCard && !dockParentExpanded && !dragging && !tiledStyle && !isMinimized && !keepAliveHidden; return ( = ({ data-select-id={browserId} data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })} // Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing. - data-keepalive-hidden={keepAliveHidden || isMinimized ? '1' : undefined} + data-keepalive-hidden={keepAliveHidden || isMinimized || dockParked ? '1' : undefined} onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { items: [ { label: 'New Tab', onClick: () => dispatch(addBrowserTab({ browserId, url: browserHomepage })) }, @@ -1011,13 +1014,13 @@ const BrowserCard: React.FC = ({ sx={{ position: 'absolute', // Kept-alive card from another dashboard: parked far off-screen so its webview surface can't bleed onto the dashboard you're viewing; click-through, webContents stays mounted. - pointerEvents: keepAliveHidden || isMinimized ? 'none' : undefined, + pointerEvents: keepAliveHidden || isMinimized || dockParked ? 'none' : undefined, // contain: webview repaints don't shake neighbor cards. 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 : dockActive ? dockRect!.x : (dragging ? cardX : displayX)), - top: tiledStyle && !(keepAliveHidden || isMinimized) ? tiledStyle.top : dockActive ? dockRect!.y : (dragging ? cardY : displayY), + left: keepAliveHidden || isMinimized || dockParked ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)), + top: tiledStyle && !(keepAliveHidden || isMinimized || dockParked) ? 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 : dockActive ? dockRect!.w : displayW, diff --git a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx index e0823568..5a923eb1 100644 --- a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Typography from '@mui/material/Typography'; import LanguageIcon from '@mui/icons-material/Language'; +import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import EditNoteIcon from '@mui/icons-material/EditNote'; import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import { MessageCircle } from 'lucide-react'; @@ -96,11 +96,8 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, not label: appName, rect: vc, tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)', - icon: ( - - {appName.charAt(0).toUpperCase()} - - ), + // Never letters in the dock: a real symbol reads as an app, a glyph initial reads as a bug. + icon: , thumbnail: output?.thumbnail, }); } diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index ef8f6640..b9a9bc31 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -261,6 +261,8 @@ export function useTethers({ // An "app:" glow key targets a VIEW card (AppAgent driving an app); everything else is a browser card. const glowTarget = (id: string) => (id.startsWith('app:') ? viewCards[id.slice(4)] : browserCards[id]); for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) { + // A docked browser renders INSIDE its chat; an arrow from the chat to it points at nothing. + if (browserCards[browserId]?.docked_to) continue; const t = cardTether( glowTarget(browserId), browserId, diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index 47757b29..b0d9cb22 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -72,7 +72,7 @@ const GeneralInterface: React.FC<{ Dictation - How the mic button and the mic key (F5) work. + {`How the mic button and the dictation shortcut (${/Mac/.test(navigator.platform) ? '\u2318\u21e7D' : 'Ctrl+Shift+D'}) work.`} ; viewCards: Record }, sessionId: string): void { + for (const bc of Object.values(state.browserCards)) { + if (bc.docked_to === sessionId) bc.docked_to = null; + } + for (const vc of Object.values(state.viewCards)) { + if (vc.docked_to === sessionId) vc.docked_to = null; + } +} + const dashboardLayoutSlice = createSlice({ name: 'dashboardLayout', initialState, @@ -852,7 +863,7 @@ const dashboardLayoutSlice = createSlice({ zOrder: state.nextZOrder++, parent_session_id: parentSessionId || null, // An agent-built app defaults to living INSIDE its chat, same as spawned browsers. - docked_to: parentSessionId || null, + docked_to: (parentSessionId && (clearOtherDocks(state, parentSessionId), parentSessionId)) || null, preview_deferred: previewDeferred || undefined, }; state.pendingFocusViewCardId = cardKey; @@ -938,11 +949,15 @@ const dashboardLayoutSlice = createSlice({ setViewDocked(state, action: PayloadAction<{ cardKey: string; dockedTo: string | null }>) { const vc = state.viewCards[action.payload.cardKey]; - if (vc) vc.docked_to = action.payload.dockedTo; + if (!vc) return; + if (action.payload.dockedTo) clearOtherDocks(state, action.payload.dockedTo); + vc.docked_to = action.payload.dockedTo; }, setBrowserDocked(state, action: PayloadAction<{ browserId: string; dockedTo: string | null }>) { const bc = state.browserCards[action.payload.browserId]; - if (bc) bc.docked_to = action.payload.dockedTo; + if (!bc) return; + if (action.payload.dockedTo) clearOtherDocks(state, action.payload.dockedTo); + bc.docked_to = action.payload.dockedTo; }, addBrowserCardFromBackend(state, action: PayloadAction) { const card = action.payload; @@ -1801,6 +1816,9 @@ const dashboardLayoutSlice = createSlice({ for (const bc of Object.values(state.browserCards)) { if (bc.spawned_by !== session.id) continue; const pos = placeBrowserBesideChat(state, parentCard, session.id, bc.width, bc.height, bc.browser_id); + // Default home is inside the chat, same as the non-racing spawn path. + clearOtherDocks(state, session.id); + bc.docked_to = session.id; bc.x = pos.x; bc.y = pos.y; state.glowingBrowserCards[bc.browser_id] = { sourceId: session.id, fading: false, label: 'Use Browser' };