diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
index f61bef35..35ccfcdd 100644
--- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
+++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
@@ -1,9 +1,10 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
-import Typography from '@mui/material/Typography';
import LanguageIcon from '@mui/icons-material/Language';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
+import KeyboardArrowUpRoundedIcon from '@mui/icons-material/KeyboardArrowUpRounded';
+import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import SettingsIcon from '@mui/icons-material/Settings';
import AppsRoundedIcon from '@mui/icons-material/AppsRounded';
@@ -15,6 +16,7 @@ import { openCardContextMenu } from './openCardContextMenu';
import { dockTileMenuRows } from './dockTileMenuRows';
import { useDockLayout } from './useDockLayout';
import { DockTileIcon } from './DockTileIcon';
+import DockHoverPreview from './DockHoverPreview';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -37,9 +39,8 @@ interface DesktopDockProps {
onAddBrowser: () => void;
}
-const PREVIEW_W = 190;
const ACTION_COUNT = 4;
-const FADE = 18;
+const CARET_H = 13;
/** Left-edge desktop dock: one tile per open card, hover previews, click focuses the window. */
function DesktopDock({
@@ -111,9 +112,10 @@ function DesktopDock({
else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false } : prev));
}, [scrolls, scrollHeight, entries.length, readEdges, scrollRef]);
- // Only fade the end that still has tiles behind it, so a magnified first or last tile stays crisp.
+ // The fade is exactly the bleed band, which (since scrolling only happens at the tile floor, where bleed > tile)
+ // is the only place a partly-scrolled tile can ever show: so a cut icon always fades out, never hard-clips.
const mask = scrolls
- ? `linear-gradient(to bottom, rgba(0,0,0,${edges.top ? 0 : 1}) 0px, #000 ${FADE}px, #000 calc(100% - ${FADE}px), rgba(0,0,0,${edges.bottom ? 0 : 1}) 100%)`
+ ? `linear-gradient(to bottom, rgba(0,0,0,${edges.top ? 0 : 1}) 0px, #000 ${bleed}px, #000 calc(100% - ${bleed}px), rgba(0,0,0,${edges.bottom ? 0 : 1}) 100%)`
: undefined;
const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined;
@@ -121,6 +123,11 @@ function DesktopDock({
? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined)
: undefined;
+ // Past the shrink floor the column scrolls, and a hidden scrollbar with no caret reads as "the rest is gone".
+ const carets: { key: string; top: number; icon: React.ReactNode }[] = [];
+ if (scrolls && edges.top) carets.push({ key: 'up', top: 0, icon: });
+ if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, icon: });
+
return (
0 && (
) => { readEdges(e.currentTarget); endHover(); } : undefined}
// The canvas zooms on wheel; a wheel we consume here must never reach it.
onWheel={scrolls ? (e: React.WheelEvent) => e.stopPropagation() : undefined}
@@ -260,36 +269,29 @@ function DesktopDock({
))}
- {hoveredEntry && (
+ {/* Anchored to the root's padding box, whose top edge IS the scroll box's top edge. */}
+ {carets.map((c) => (
- {previewImage ? (
-
- ) : (
-
-
- {hoveredEntry.label}
-
- {hoveredEntry.snippet && (
-
- {hoveredEntry.snippet}
-
- )}
-
- )}
+ {c.icon}
- )}
+ ))}
+
+ {hoveredEntry && }
);
}
diff --git a/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx b/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx
new file mode 100644
index 00000000..0eb31f99
--- /dev/null
+++ b/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import type { DockEntry } from './dockEntries';
+
+const PREVIEW_W = 190;
+
+interface DockHoverPreviewProps {
+ entry: DockEntry;
+ top: number;
+ image?: string;
+}
+
+/** The card that floats beside a hovered dock tile: a live shot when we have one, title + snippet otherwise. */
+function DockHoverPreview({ entry, top, image }: DockHoverPreviewProps): React.ReactElement {
+ return (
+
+ {image ? (
+
+ ) : (
+
+
+ {entry.label}
+
+ {entry.snippet && (
+
+ {entry.snippet}
+
+ )}
+
+ )}
+
+ );
+}
+
+export default DockHoverPreview;
diff --git a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts
index fb6e15dc..e0121498 100644
--- a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts
+++ b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts
@@ -1,16 +1,16 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
const TILE_MAX = 30;
-const TILE_MIN = 18;
+// Apple's floor is deliberately tiny: magnification, not tile size, is what keeps a small tile hittable.
+const TILE_MIN = 14;
const ROOT_PAD = 7;
const GAP_RATIO = 0.3;
const GAP_MIN = 3;
const ICON_RATIO = 0.58;
// Breathing room above and below the dock; the canvas root clips overflow, so this is also the magnify headroom.
const EDGE_MARGIN = 16;
-// Slack inside the scroll box so a magnified tile grows past the column without the scroll clip cutting it.
-const BLEED = 14;
-const BOOST = 0.5;
+// macOS magnifies to an ABSOLUTE size, not a fixed ratio, so a 14px tile still grows to something you can hit.
+const MAGNIFY_TARGET = 44;
// The bell curve was hand-tuned as 44px against a 30px tile; keep that ratio so it narrows as tiles shrink.
const FALLOFF_RATIO = 44 / 30;
@@ -41,6 +41,26 @@ function columnHeight(tile: number, tiles: number, dividers: number): number {
return ROOT_PAD * 2 + tiles * tile + dividers + gaps * gapFor(tile);
}
+interface DockGeom {
+ els: HTMLElement[];
+ bases: number[];
+ rootTop: number;
+ written: string[];
+}
+
+function beginGesture(root: HTMLElement, box: HTMLDivElement | null): DockGeom {
+ const els = Array.from(root.querySelectorAll('.osw-dock-tile'));
+ const boxShift = box ? box.offsetTop - box.scrollTop : 0;
+ // The curve already tracks the cursor frame by frame; easing every tile on top of that is a tax, not motion.
+ els.forEach((t) => { t.style.transition = 'none'; });
+ return {
+ els,
+ bases: els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2),
+ rootTop: root.getBoundingClientRect().top,
+ written: els.map(() => ''),
+ };
+}
+
/** macOS Dock sizing: tiles shrink to fit the column and only scroll once they hit the floor. */
export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayoutInput): DockLayout {
const dockRef = useRef(null);
@@ -67,41 +87,74 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
while (tile > TILE_MIN && columnHeight(tile, tileCount, dividerCount) > budget) tile -= 1;
const gap = gapFor(tile);
const scrolls = columnHeight(tile, tileCount, dividerCount) > budget;
+ // Slack inside the scroll box so a magnified tile grows past the column without the scroll clip cutting it.
+ const bleed = Math.ceil((MAGNIFY_TARGET - tile) / 2) + 2;
// Pinned rows keep their full height; whatever is left is what the card column may occupy.
const pinned = ROOT_PAD * 2 + dividerCount + actionCount * tile + (dividerCount + actionCount) * gap;
- const scrollHeight = Math.max(tile * 3 + gap * 2 + BLEED * 2, budget - pinned);
+ const step = tile + gap;
+ // Whole tile steps only, so the clip edge always lands in a gap instead of bisecting an icon.
+ const rows = Math.max(1, Math.floor((budget - pinned - bleed * 2 + gap) / step));
+ const scrollHeight = rows * step - gap + bleed * 2;
const tileRef = useRef(tile);
tileRef.current = tile;
+ const geomRef = useRef(null);
+ const dropGeom = useCallback((): void => { geomRef.current = null; }, []);
+
+ // Tiles cannot move mid-hover, so re-measuring them per mousemove was pure style-recalc tax; these are
+ // every signal that CAN move them.
+ useEffect(dropGeom, [dropGeom, tile, gap, scrollHeight, scrolls, containerH, cardCount, actionCount]);
+
+ useEffect(() => {
+ const box = scrollRef.current;
+ if (!box) return undefined;
+ box.addEventListener('scroll', dropGeom, { passive: true });
+ return () => box.removeEventListener('scroll', dropGeom);
+ }, [dropGeom, scrolls, cardCount]);
// macOS Dock magnification: the tile under the cursor grows on a bell curve and its neighbors SLIDE
// AWAY to make room. Each tile that grows by `extra` pushes every tile past it by extra/2.
const applyMagnify = useCallback((clientY: number | null) => {
const root = dockRef.current;
if (!root) return;
- const els = Array.from(root.querySelectorAll('.osw-dock-tile'));
- if (els.length === 0) return;
if (clientY == null) {
- els.forEach((t) => { t.style.transform = ''; t.style.zIndex = ''; });
+ // Handing the transition back before clearing the transform is what makes the settle glide instead of snap.
+ root.querySelectorAll('.osw-dock-tile').forEach((t) => {
+ t.style.transition = '';
+ t.style.transform = '';
+ t.style.zIndex = '';
+ });
+ geomRef.current = null;
return;
}
+ const geom = geomRef.current ?? beginGesture(root, scrollRef.current);
+ geomRef.current = geom;
+ const { els, bases } = geom;
+ if (els.length === 0) return;
const size = tileRef.current;
+ const boost = MAGNIFY_TARGET / size - 1;
const falloff = size * FALLOFF_RATIO;
- const box = scrollRef.current;
- const boxShift = box ? box.offsetTop - box.scrollTop : 0;
- const cy = clientY - root.getBoundingClientRect().top;
- const bases = els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2);
- const scales = bases.map((b) => 1 + BOOST * Math.exp(-(((cy - b) / falloff) ** 2)));
+ const cy = clientY - geom.rootTop;
+ const scales = bases.map((b) => 1 + boost * Math.exp(-(((cy - b) / falloff) ** 2)));
const extra = scales.map((s) => size * (s - 1));
+ const total = extra.reduce((a, b) => a + b, 0);
+ // Bases run in DOM order, top to bottom, so "everything before me" is a running sum, not an inner loop.
+ const head = (extra[0] - total) / 2;
+ const tail = (total - extra[els.length - 1]) / 2;
+ const span = bases[els.length - 1] - bases[0];
+ // Apple's Dock never grows longer than its rail: pin both ends and let the spread squeeze the middle.
+ const slope = span > 0 ? (tail - head) / span : 0;
+ let before = 0;
els.forEach((t, i) => {
- let shift = 0;
- for (let j = 0; j < els.length; j++) {
- if (j === i) continue;
- shift += (extra[j] / 2) * Math.sign(bases[i] - bases[j]);
- }
- t.style.transform = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
- t.style.transformOrigin = 'left center';
+ const raw = before - (total - before - extra[i]);
+ const shift = raw / 2 - head - slope * (bases[i] - bases[0]);
+ before += extra[i];
+ const next = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
+ // Tiles outside the bell curve land on the same transform move after move, and a no-op style write is not free.
+ if (geom.written[i] === next) return;
+ geom.written[i] = next;
+ t.style.transform = next;
t.style.zIndex = String(10 + Math.round((scales[i] - 1) * 100));
});
}, []);
@@ -114,7 +167,7 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
iconSize: Math.round(tile * ICON_RATIO),
scrolls,
scrollHeight,
- bleed: BLEED,
+ bleed,
applyMagnify,
};
}