[eric] desktop: fullscreen is the arc layout (persistent dock rail, card fills the dashboard, pinned: no drag/collapse/camera until restored)

This commit is contained in:
ciregenz
2026-07-20 23:43:42 -07:00
parent ea4e014803
commit d015747d40
7 changed files with 26 additions and 73 deletions
@@ -203,60 +203,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
return () => window.removeEventListener('mousemove', onMove);
}, [fullscreenCardId]);
// Arc/Zen fullscreen: the dock hides with the rest of the chrome but slides back on a left-edge
// graze, and clicking a tile SWAPS which card owns the full screen instead of moving the camera.
// Hover semantics are a 1:1 port of Zen's compact mode (ZenCompactMode.mjs + compact-mode.yaml):
// sidebar-keep-hover 150ms grace after leaving, 1000ms retention when the cursor exits the window
// across the dock's screen edge, and an 800ms flash when the active (fullscreen) card switches.
const FS_KEEP_HOVER_MS = 150;
const FS_WINDOW_LEAVE_MS = 1000;
const FS_FLASH_MS = 800;
const [fsDockRevealed, setFsDockRevealed] = React.useState(false);
const fsDockRef = React.useRef<HTMLDivElement | null>(null);
const fsRevealedRef = React.useRef(false);
fsRevealedRef.current = fsDockRevealed;
useEffect(() => {
if (!fullscreenCardId) { setFsDockRevealed(false); return undefined; }
let hideTimer: number | null = null;
const cancelHide = (): void => { if (hideTimer != null) { window.clearTimeout(hideTimer); hideTimer = null; } };
const scheduleHide = (ms: number): void => { cancelHide(); hideTimer = window.setTimeout(() => setFsDockRevealed(false), ms); };
const overDock = (x: number, y: number): boolean => {
const el = fsDockRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left - 8 && x <= r.right + 8 && y >= r.top - 8 && y <= r.bottom + 8;
};
const onMove = (e: MouseEvent): void => {
if (e.clientX <= 16 || overDock(e.clientX, e.clientY)) {
cancelHide();
if (!fsRevealedRef.current) setFsDockRevealed(true);
} else if (fsRevealedRef.current && hideTimer == null) {
scheduleHide(FS_KEEP_HOVER_MS);
}
};
// Cursor flung out of the window across the dock's edge: keep the dock up (Zen's screen-edge
// retention), then let the next in-window move decide.
const onOut = (e: MouseEvent): void => {
if (!e.relatedTarget && e.clientX <= 24 && fsRevealedRef.current) scheduleHide(FS_WINDOW_LEAVE_MS);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseout', onOut);
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseout', onOut); cancelHide(); };
}, [fullscreenCardId]);
// Zen flashes the sidebar on tab switch in compact mode; swapping the fullscreen card is our tab switch.
const prevFsCardRef = React.useRef<string | null>(null);
useEffect(() => {
const prev = prevFsCardRef.current;
prevFsCardRef.current = fullscreenCardId;
if (!fullscreenCardId || !prev || prev === fullscreenCardId) return undefined;
setFsDockRevealed(true);
const t = window.setTimeout(() => {
const el = fsDockRef.current;
if (el && el.matches(':hover')) return;
setFsDockRevealed(false);
}, FS_FLASH_MS);
return () => window.clearTimeout(t);
}, [fullscreenCardId]);
// Arc fullscreen layout: the dock rail STAYS PUT (Arc's persistent sidebar) while the card
// expands into the whole dashboard beside it; clicking a tile SWAPS which card owns the screen.
const swapFullscreen = React.useCallback((cardId: string) => {
if (!fullscreenCardId || cardId === fullscreenCardId) return;
dispatch(clearTiledCard(fullscreenCardId));
@@ -325,16 +273,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
)}
{(!fullscreenCardId || fsDockRevealed) && (
{(
<Box
ref={fsDockRef}
sx={fullscreenCardId ? {
// Explicit width: the dock inside is position:absolute so the wrapper would collapse to
// its padding, and the hover-keep hit test would miss the dock entirely.
position: 'absolute', left: 0, top: 0, bottom: 0, width: 64, zIndex: 999995,
display: 'flex', alignItems: 'center', pl: '4px',
animation: 'osw-fsdock-in 160ms ease-out',
'@keyframes osw-fsdock-in': { from: { transform: 'translateX(-52px)', opacity: 0 }, to: { transform: 'translateX(0)', opacity: 1 } },
} : undefined}
>
<DesktopDock
@@ -472,8 +472,10 @@ const AgentCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[session.id]);
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
if (tileZone) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
@@ -483,7 +485,7 @@ const AgentCard: React.FC<Props> = ({
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(session.id, 'agent');
}, [cardX, cardY, onDragStart, session.id, getCanvasState]);
}, [cardX, cardY, onDragStart, session.id, getCanvasState, tileZone]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -656,7 +658,6 @@ const AgentCard: React.FC<Props> = ({
}
};
const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[session.id]);
const isFullscreen = tileZone === 'fullscreen';
// Fullscreen pins the card to the viewport, so while tiled the geometry must track canvas pan/zoom.
// Chat cards read the camera via a getter (not props) to avoid re-rendering on every pan tick, so
@@ -712,6 +712,7 @@ const BrowserCard: React.FC<Props> = ({
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
if (tileZone) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
@@ -721,7 +722,7 @@ const BrowserCard: React.FC<Props> = ({
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(browserId, 'browser');
}, [cardX, cardY, onDragStart, browserId, getCanvasState]);
}, [cardX, cardY, onDragStart, browserId, getCanvasState, tileZone]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -242,6 +242,7 @@ const DashboardViewCard: React.FC<Props> = ({
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
if (tileZone) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
@@ -251,7 +252,7 @@ const DashboardViewCard: React.FC<Props> = ({
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(cardKey, 'view');
}, [cardX, cardY, onDragStart, cardKey, getCanvasState]);
}, [cardX, cardY, onDragStart, cardKey, getCanvasState, tileZone]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -108,6 +108,7 @@ const NoteCard: React.FC<Props> = ({
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
if (tileZone) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
@@ -121,7 +122,7 @@ const NoteCard: React.FC<Props> = ({
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(noteId, 'note');
}, [cardX, cardY, noteId, onDragStart, getCanvasState]);
}, [cardX, cardY, noteId, onDragStart, getCanvasState, tileZone]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -23,6 +23,8 @@ export const TILE_ZONES: Record<string, { x: number; y: number; w: number; h: nu
// macOS Sequoia leaves a small gap between tiled windows; we match it.
const GAP = 8;
// Width the persistent dock rail owns while a card is fullscreen (the Arc sidebar analog).
export const FULLSCREEN_DOCK_RAIL = 64;
export interface TiledStyle {
left: number;
@@ -46,20 +48,19 @@ function workspaceSize(): { w: number; h: number } {
}
export function computeTiledStyle(zone: string, panX: number, panY: number, zoom: number): TiledStyle | null {
// 'fullscreen' = macOS full screen: the app chrome hides (AppShell/DashboardCanvas react to
// selectFullscreenCardId) and the card covers the WHOLE window. Target is WINDOW space here, so
// the viewport origin does NOT cancel; once the chrome collapses that origin goes to ~0 anyway.
// 'fullscreen' = the Arc layout: the app chrome hides except the dock rail, which stays put on
// the left, and the card expands into the ENTIRE dashboard beside it, floating on the same gap
// the tile zones use. Target is WINDOW space here, so the viewport origin does NOT cancel.
if (zone === 'fullscreen') {
const PEEK = 0;
const el = document.querySelector('[data-canvas-viewport]');
const r = el ? el.getBoundingClientRect() : null;
const ox = r ? r.left : 0;
const oy = r ? r.top : 0;
return {
left: (PEEK - ox - panX) / zoom,
top: (PEEK - oy - panY) / zoom,
width: window.innerWidth - PEEK * 2,
height: window.innerHeight - PEEK * 2,
left: (FULLSCREEN_DOCK_RAIL + GAP - ox - panX) / zoom,
top: (GAP - oy - panY) / zoom,
width: window.innerWidth - FULLSCREEN_DOCK_RAIL - GAP * 2,
height: window.innerHeight - GAP * 2,
transform: `scale(${1 / zoom})`,
transformOrigin: 'top left',
};
@@ -73,6 +73,10 @@ export function useDashboardInteractions({
// The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom.
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
// A tiled (fullscreen/snapped) card is pinned Arc-style: clicking inside it must not collapse
// it or glide the camera; it leaves the mode via its own controls (yellow, Esc, dock swap).
if (store.getState().dashboardLayout.tiledCards[id]) return;
const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id);
if (alreadyExpanded) {
@@ -206,6 +210,7 @@ export function useDashboardInteractions({
}
dispatch(bringToFront({ id, type }));
setFocusedCardId(id);
if (store.getState().dashboardLayout.tiledCards[id]) return;
setTimeout(() => {
const rect = getCardRect(id, type);
if (rect) canvas.actions.fitToCards([rect], 1.15, true);