mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] dock: the tile floor is 20px, the column snaps to whole tiles, and each edge says how many chats are past it and pages on click
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0001dfe9af
commit
96aa7ebbf2
@@ -10,7 +10,7 @@ import { getWebview } from '@/shared/browserRegistry';
|
||||
import { buildDockEntries, CardRect, DockEntry } from './dockEntries';
|
||||
import { openCardContextMenu } from './openCardContextMenu';
|
||||
import { dockTileMenuRows } from './dockTileMenuRows';
|
||||
import { useDockLayout } from './useDockLayout';
|
||||
import { hiddenCounts, useDockLayout } from './useDockLayout';
|
||||
import { DockTileIcon } from './DockTileIcon';
|
||||
import DockActionTiles, { DOCK_ACTION_COUNT } from './DockActionTiles';
|
||||
import DockHoverPreview from './DockHoverPreview';
|
||||
@@ -53,7 +53,7 @@ function DesktopDock({
|
||||
const accent = useClaudeTokens().accent.primary;
|
||||
const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null);
|
||||
const [liveShot, setLiveShot] = useState<{ id: string; dataUrl: string } | null>(null);
|
||||
const [edges, setEdges] = useState<{ top: boolean; bottom: boolean }>({ top: false, bottom: false });
|
||||
const [edges, setEdges] = useState<{ top: boolean; bottom: boolean; above: number; below: number }>({ top: false, bottom: false, above: 0, below: 0 });
|
||||
const hoverTimer = useRef<number | null>(null);
|
||||
|
||||
// The dock consumes only name + turn_label per session, but the whole-dict identity changes on
|
||||
@@ -66,7 +66,7 @@ function DesktopDock({
|
||||
[sessionsKey, cards, viewCards, browserCards, workflowCards, outputs],
|
||||
);
|
||||
|
||||
const { dockRef, scrollRef, tile, gap, iconSize, scrolls, scrollHeight, bleed, applyMagnify } = useDockLayout({
|
||||
const { dockRef, scrollRef, tile, gap, step, iconSize, scrolls, scrollHeight, bleed, applyMagnify } = useDockLayout({
|
||||
cardCount: entries.length,
|
||||
actionCount: DOCK_ACTION_COUNT,
|
||||
dividerCount: entries.length > 0 ? 2 : 1,
|
||||
@@ -99,13 +99,14 @@ function DesktopDock({
|
||||
const readEdges = useCallback((el: HTMLDivElement) => {
|
||||
const top = el.scrollTop > 1;
|
||||
const bottom = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
||||
setEdges((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }));
|
||||
}, []);
|
||||
const { above, below } = hiddenCounts(el.scrollTop, el.clientHeight - bleed * 2, el.scrollHeight - bleed * 2, step);
|
||||
setEdges((prev) => (prev.top === top && prev.bottom === bottom && prev.above === above && prev.below === below ? prev : { top, bottom, above, below }));
|
||||
}, [bleed, step]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && scrolls) readEdges(el);
|
||||
else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false } : prev));
|
||||
else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false, above: 0, below: 0 } : prev));
|
||||
}, [scrolls, scrollHeight, entries.length, readEdges, scrollRef]);
|
||||
|
||||
// The fade is exactly the bleed band, which (since scrolling only happens at the tile floor, where bleed > tile)
|
||||
@@ -119,10 +120,15 @@ 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: <KeyboardArrowUpRoundedIcon sx={{ fontSize: '0.75rem' }} /> });
|
||||
if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, icon: <KeyboardArrowDownRoundedIcon sx={{ fontSize: '0.75rem' }} /> });
|
||||
// Past the shrink floor the column scrolls, and a hidden scrollbar with a faded last tile read as "cut off"
|
||||
// (Eric, 2026-09-03). Each edge now says how many tiles are past it, and a click pages that way.
|
||||
const pageBy = (direction: 1 | -1) => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollBy({ top: direction * Math.max(step, el.clientHeight - bleed * 2 - step), behavior: 'smooth' });
|
||||
};
|
||||
const carets: { key: string; top: number; count: number; icon: React.ReactNode; onClick: () => void }[] = [];
|
||||
if (scrolls && edges.top) carets.push({ key: 'up', top: 0, count: edges.above, icon: <KeyboardArrowUpRoundedIcon sx={{ fontSize: '0.75rem' }} />, onClick: () => pageBy(-1) });
|
||||
if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, count: edges.below, icon: <KeyboardArrowDownRoundedIcon sx={{ fontSize: '0.75rem' }} />, onClick: () => pageBy(1) });
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -175,6 +181,10 @@ function DesktopDock({
|
||||
height: `${scrollHeight}px`,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
// A wheel settles on a whole tile, so the clip edge lands in a gap instead of bisecting an icon.
|
||||
scrollSnapType: 'y proximity',
|
||||
scrollPaddingTop: `${bleed}px`,
|
||||
scrollPaddingBottom: `${bleed}px`,
|
||||
// Bleed the clip box past the column so scrolling doesn't crop the magnified tiles.
|
||||
width: `${tile + bleed * 2}px`,
|
||||
mx: `${-bleed}px`,
|
||||
@@ -217,6 +227,7 @@ function DesktopDock({
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
scrollSnapAlign: 'start',
|
||||
transition: 'box-shadow 140ms ease, background 140ms ease',
|
||||
// Same grammar as the minimized rail: soft accent tint, ONE accent inner ring as the carrier, and the icon lifts. The outer glow is decoration, never the signal.
|
||||
...(isActive && {
|
||||
@@ -243,6 +254,12 @@ function DesktopDock({
|
||||
{carets.map((c) => (
|
||||
<Box
|
||||
key={c.key}
|
||||
data-dock-edge={c.key}
|
||||
data-dock-hidden={c.count}
|
||||
role="button"
|
||||
aria-label={`${c.count} more ${c.key === 'up' ? 'above' : 'below'}`}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); endHover(); c.onClick(); }}
|
||||
onMouseEnter={endHover}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
@@ -252,11 +269,17 @@ function DesktopDock({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
pointerEvents: 'none',
|
||||
gap: '1px',
|
||||
fontSize: '0.5625rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
color: 'rgba(255,255,255,0.78)',
|
||||
cursor: 'pointer',
|
||||
zIndex: 40,
|
||||
'&:hover': { color: '#fff' },
|
||||
}}
|
||||
>
|
||||
{c.count > 0 ? <span>{c.count}</span> : null}
|
||||
{c.icon}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { hiddenCounts, TILE_MIN } from './useDockLayout';
|
||||
|
||||
// Sixty chats shrank the rail to 14px glyphs and the faded last tile read as "cut off hella" (Eric,
|
||||
// 2026-09-03). The floor is legible, a wheel settles on whole tiles, and each edge says how many
|
||||
// tiles are past it and pages that way on click.
|
||||
|
||||
test('the tile floor is legible', () => {
|
||||
assert.ok(TILE_MIN >= 20, `floor ${TILE_MIN}`);
|
||||
});
|
||||
|
||||
test('hidden counts are whole tiles above and below the clip box', () => {
|
||||
const step = 26;
|
||||
assert.deepEqual(hiddenCounts(0, 520, 1560, step), { above: 0, below: 40 });
|
||||
assert.deepEqual(hiddenCounts(520, 520, 1560, step), { above: 20, below: 20 });
|
||||
assert.deepEqual(hiddenCounts(1040, 520, 1560, step), { above: 40, below: 0 });
|
||||
assert.deepEqual(hiddenCounts(0, 520, 400, step), { above: 0, below: 0 });
|
||||
});
|
||||
|
||||
test('the column snaps to tiles and the edge chips carry the count and a click', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Dashboard/desktop/DesktopDock.tsx'), 'utf8');
|
||||
assert.match(src, /scrollSnapType: 'y proximity'/);
|
||||
assert.match(src, /scrollSnapAlign: 'start'/);
|
||||
assert.match(src, /data-dock-hidden=\{c\.count\}/);
|
||||
assert.match(src, /el\.scrollBy\(\{ top: direction/);
|
||||
assert.doesNotMatch(src, /pointerEvents: 'none',\s*zIndex: 40/, 'the edge chip must be clickable');
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
// ENG-331: at 40+ chats the rail runs at the 14px floor, where the bell curve is at its widest
|
||||
// ENG-331: past the floor the bell curve is at its widest; the floor moved 14 -> 20 on 2026-09-03 (60 chats were unreadable)
|
||||
// relative to the tile; these pin that the curve never crosses the entries/actions divider.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeMagnifyTransforms } from './useDockLayout';
|
||||
|
||||
// The curve math is exercised at the old 14px floor on purpose: the numbers below were tuned there and the function is pure.
|
||||
const TILE = 14;
|
||||
const STEP = 18;
|
||||
const ENTRIES = 40;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const TILE_MAX = 30;
|
||||
// Apple's floor is deliberately tiny: magnification, not tile size, is what keeps a small tile hittable.
|
||||
const TILE_MIN = 14;
|
||||
// Apple's floor is tinier, but a 14px chat glyph was unreadable at 60 chats (Eric, 2026-09-03); past this the column scrolls.
|
||||
export const TILE_MIN = 20;
|
||||
const ROOT_PAD = 7;
|
||||
const GAP_RATIO = 0.3;
|
||||
const GAP_MIN = 3;
|
||||
@@ -25,6 +25,7 @@ export interface DockLayout {
|
||||
scrollRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
tile: number;
|
||||
gap: number;
|
||||
step: number;
|
||||
iconSize: number;
|
||||
scrolls: boolean;
|
||||
scrollHeight: number;
|
||||
@@ -32,6 +33,14 @@ export interface DockLayout {
|
||||
applyMagnify: (clientY: number | null) => void;
|
||||
}
|
||||
|
||||
/** How many whole tiles sit above and below the clip box at this scroll position. */
|
||||
export function hiddenCounts(scrollTop: number, clientHeight: number, scrollHeight: number, step: number): { above: number; below: number } {
|
||||
if (step <= 0 || scrollHeight <= clientHeight) return { above: 0, below: 0 };
|
||||
const above = Math.max(0, Math.round(scrollTop / step));
|
||||
const below = Math.max(0, Math.round((scrollHeight - scrollTop - clientHeight) / step));
|
||||
return { above, below };
|
||||
}
|
||||
|
||||
function gapFor(tile: number): number {
|
||||
return Math.max(GAP_MIN, Math.round(tile * GAP_RATIO));
|
||||
}
|
||||
@@ -209,6 +218,7 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
|
||||
scrollRef,
|
||||
tile,
|
||||
gap,
|
||||
step,
|
||||
iconSize: Math.round(tile * ICON_RATIO),
|
||||
scrolls,
|
||||
scrollHeight,
|
||||
|
||||
Reference in New Issue
Block a user