[eric] desktop: fullscreen dock ports zen compact-mode hover semantics; stale-tile states sealed (close, dashboard switch, collapse)

This commit is contained in:
ciregenz
2026-07-20 22:23:31 -07:00
parent 0b19797f7f
commit dd4d6f3faa
2 changed files with 65 additions and 8 deletions
@@ -186,32 +186,76 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
return () => window.removeEventListener('keydown', onKey, true);
}, [fullscreenCardId, dispatch]);
// Arc-style chrome: the mac traffic lights ride the same top-edge hover as the header overlay.
// Arc-style chrome: the mac traffic lights ride the top-edge hover, in fullscreen too (Arc/Zen both
// keep the native buttons reachable in compact/fullscreen; Zen even exempts them from hover-leave).
useEffect(() => {
window.openswarm?.setWindowButtonsVisible?.(headerRevealed && !fullscreenCardId);
}, [headerRevealed, fullscreenCardId]);
window.openswarm?.setWindowButtonsVisible?.(headerRevealed);
}, [headerRevealed]);
// Reveal on any pointer graze of the top edge. The old 22px strip Box was dead in practice: the
// hidden header overlay's pointer-events:auto children sat above it and ate the mouseenter.
useEffect(() => {
const onMove = (e: MouseEvent): void => {
if (e.clientY <= 22) setHeaderRevealed(true);
else if (fullscreenCardId && e.clientY > 80) setHeaderRevealed(false);
};
window.addEventListener('mousemove', onMove);
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) setFsDockRevealed(true);
else if (e.clientX > 120) setFsDockRevealed(false);
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);
return () => window.removeEventListener('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]);
const swapFullscreen = React.useCallback((cardId: string) => {
if (!fullscreenCardId || cardId === fullscreenCardId) return;
@@ -283,8 +327,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
{(!fullscreenCardId || fsDockRevealed) && (
<Box
ref={fsDockRef}
sx={fullscreenCardId ? {
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 999995,
// 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 } },
@@ -694,6 +694,8 @@ const dashboardLayoutSlice = createSlice({
removeCard(state, action: PayloadAction<string>) {
delete state.cards[action.payload];
delete state.tiledCards[action.payload];
delete state.minimizedCards[action.payload];
},
reconcileSessions(
@@ -866,6 +868,8 @@ const dashboardLayoutSlice = createSlice({
removeViewCard(state, action: PayloadAction<string>) {
delete state.viewCards[action.payload];
delete state.tiledCards[action.payload];
delete state.minimizedCards[action.payload];
if (state.activeViewCardId === action.payload) state.activeViewCardId = null;
},
@@ -945,6 +949,8 @@ const dashboardLayoutSlice = createSlice({
delete state.browserCards[action.payload];
delete state.suspendedBrowserCards[action.payload];
delete state.endingBrowserCards[action.payload];
delete state.tiledCards[action.payload];
delete state.minimizedCards[action.payload];
},
markBrowserCardEnding(
@@ -1484,6 +1490,8 @@ const dashboardLayoutSlice = createSlice({
removeNote(state, action: PayloadAction<string>) {
delete state.notes[action.payload];
delete state.tiledCards[action.payload];
delete state.minimizedCards[action.payload];
},
clearPendingFocusNoteId(state) {
@@ -1614,6 +1622,8 @@ const dashboardLayoutSlice = createSlice({
},
resetLayout(state, action: PayloadAction<{ keepBrowserIds?: string[] } | undefined>) {
state.tiledCards = {};
state.minimizedCards = {};
// Keep the recently-used (keep-alive) browser cards mounted across a dashboard switch so their webContents + sessionStorage survive (logged-in sites stay logged in); everything else is wiped for the fresh load. Their suspend entry rides along so a parked one isn't silently dropped.
const keep = new Set(action.payload?.keepBrowserIds || []);
const keptBrowsers: typeof state.browserCards = {};