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, }; }