[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

This commit is contained in:
ciregenz
2026-07-28 14:12:26 -07:00
parent 1a094f8dea
commit ed9085eae1
7 changed files with 37 additions and 17 deletions
@@ -2407,7 +2407,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
data-browser-slot={id}
sx={{
flexShrink: 0,
height: 320,
height: 'clamp(240px, 42%, 560px)',
mx: 1.5,
mb: 1,
borderRadius: '10px',
@@ -710,10 +710,10 @@ const AgentCard: React.FC<Props> = ({
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;
});
@@ -973,6 +973,9 @@ const BrowserCard: React.FC<Props> = ({
: 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 (
<Box
@@ -982,7 +985,7 @@ const BrowserCard: React.FC<Props> = ({
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<Props> = ({
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,
@@ -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: (
<Typography sx={{ fontSize: 14, fontWeight: 700, color: '#fff', lineHeight: 1 }}>
{appName.charAt(0).toUpperCase()}
</Typography>
),
// Never letters in the dock: a real symbol reads as an app, a glyph initial reads as a bug.
icon: <GridViewRoundedIcon sx={{ fontSize: 16, color: '#fff' }} />,
thumbnail: output?.thumbnail,
});
}
@@ -261,6 +261,8 @@ export function useTethers({
// An "app:<output_id>" 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,
@@ -72,7 +72,7 @@ const GeneralInterface: React.FC<{
<Box sx={inlineRowSx} {...settingSelectAttrs('voice_hold_to_talk', 'Dictation', 'Interface', 'Hold to talk, or tap to start and stop.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Dictation</Typography>
<Typography sx={descSx}>How the mic button and the mic key (F5) work.</Typography>
<Typography sx={descSx}>{`How the mic button and the dictation shortcut (${/Mac/.test(navigator.platform) ? '\u2318\u21e7D' : 'Ctrl+Shift+D'}) work.`}</Typography>
</Box>
<ToggleButtonGroup
value={form.voice_hold_to_talk ?? true}
@@ -571,6 +571,17 @@ function addMissingCards<T extends { x: number; y: number; width: number; height
}
}
// One docked surface per chat: the slot is a single rect, so docking a new browser/app releases
// whatever was previously docked there (it falls back to its stored free position).
function clearOtherDocks(state: { browserCards: Record<string, BrowserCardPosition>; viewCards: Record<string, ViewCardPosition> }, 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<BrowserCardPosition>) {
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' };