diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 249532c916..760f4c0261 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -280,8 +280,9 @@ } /* Subtle scrollbar: a thin, rounded thumb that only appears on hover, on both - axes. Shared by ScrollableX (tab strips, code blocks) and the AI chat. Size - via the `--wm-scrollbar-size` var (default 6px). Higher specificity than the + axes. Shared by ScrollableX (code blocks, tab headers) and the AI chat. Size + via the `--wm-scrollbar-size` var (default 6px) — WebKit only; Firefox sizes + `thin` itself and spends ~11px of the box on it. Higher specificity than the app-wide `*::-webkit-scrollbar`, so it overrides it. */ .scrollbar-subtle { scrollbar-width: thin; diff --git a/frontend/src/lib/components/common/ScrollableX.svelte b/frontend/src/lib/components/common/ScrollableX.svelte index 836439bfbb..698c7f57c9 100644 --- a/frontend/src/lib/components/common/ScrollableX.svelte +++ b/frontend/src/lib/components/common/ScrollableX.svelte @@ -4,7 +4,9 @@ // Subtle horizontal scroll: native overflow (so wheel/trackpad/drag always // work) with the shared `.scrollbar-subtle` thumb (thin, hover-revealed). Bar // thickness is tunable via the `--wm-scrollbar-size` CSS var (pass through - // `style`), so denser callers (e.g. the tab strip) can shrink it. + // `style`) — WebKit only, so Firefox spends its own ~11px of the box on the + // bar whatever this says. Not for a height-constrained row: draw the thumb + // yourself there, the way the tab strip does. let { class: c = '', style = '', diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte index c1b610d49f..d89bd37fa6 100644 --- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -29,7 +29,6 @@ import { X } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import { untrack } from 'svelte' - import ScrollableX from '../ScrollableX.svelte' interface Props { tabs: TabItem[] @@ -41,7 +40,9 @@ * activated via Enter/Space — lets the active tab host a secondary affordance * (e.g. toggling the breadcrumb picker rendered in `tabAccessory`). */ onActiveClick?: (id: string) => void - /** Extra classes for the outer tab strip. */ + /** Extra classes for the outer tab strip. It is 32px tall unless `trailing` + * content is taller; a fixed height here overrides that, and going taller + * pulls the tabs away from the scroll bar, which stays on the bottom edge. */ class?: string /** Render inside the scroll row, right after the last tab (e.g. a "+" new-tab * button) — scrolls with the tabs, unlike `trailing`. */ @@ -88,6 +89,85 @@ if (!isDragging) dndMiddle = next }) + // Scroll bar. The native one is hidden (`no-scrollbar`) and redrawn here: its + // height is a WebKit-only setting, so Firefox spends 11px of the strip on a + // bar there is no room for and clips the tabs. Ours is 4px in every engine and + // costs no layout height at all. + const MIN_THUMB = 24 + let scrollEl = $state(undefined) + let scrollLeft = $state(0) + let viewport = $state(0) + let content = $state(0) + const scrollable = $derived(Math.max(0, content - viewport)) + const overflowing = $derived(scrollable > 1) + // Clamped to the viewport: a pane dragged shut leaves a few pixels of strip, + // and an unclamped minimum-width thumb would hang out of it. + const thumbWidth = $derived( + overflowing ? Math.min(viewport, Math.max(MIN_THUMB, (viewport / content) * viewport)) : 0 + ) + // Clamped at both ends: `scrollLeft` is fractional on HiDPI while the widths + // are rounded, so the ratio can tip past 1, and WebKit's elastic overscroll + // drives it negative — either way the thumb would leave the track. + const thumbLeft = $derived( + scrollable > 0 + ? Math.max( + 0, + Math.min(viewport - thumbWidth, (scrollLeft / scrollable) * (viewport - thumbWidth)) + ) + : 0 + ) + + function measure() { + const el = scrollEl + if (!el) return + scrollLeft = el.scrollLeft + viewport = el.clientWidth + content = el.scrollWidth + } + + // Both ends move independently: the viewport on a pane resize, the content as + // tabs open, close and get renamed. + $effect(() => { + const el = scrollEl + if (!el) return + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + if (el.firstElementChild) ro.observe(el.firstElementChild) + return () => ro.disconnect() + }) + + // Drag the thumb: pointer capture keeps the gesture alive past the strip's + // edges, and the ratio maps thumb travel back onto scroll travel. Recomputing + // from the anchor each move (rather than accumulating) means clamping at + // either end doesn't drift, and reading the travel live keeps a tab opening + // mid-drag from scaling every later move against a stale track. + function handleThumbPointerDown(e: PointerEvent) { + const el = scrollEl + // Primary button only: a right-click would open the context menu without + // delivering the pointerup that ends the drag. + if (!el || e.button !== 0) return + e.preventDefault() + const target = e.currentTarget as HTMLElement + const startX = e.clientX + const startScroll = el.scrollLeft + target.setPointerCapture(e.pointerId) + const onMove = (ev: PointerEvent) => { + const travel = viewport - thumbWidth + if (travel <= 0) return + el.scrollLeft = startScroll + ((ev.clientX - startX) / travel) * scrollable + } + const onUp = (ev: PointerEvent) => { + target.releasePointerCapture(ev.pointerId) + target.removeEventListener('pointermove', onMove) + target.removeEventListener('pointerup', onUp) + target.removeEventListener('pointercancel', onUp) + } + target.addEventListener('pointermove', onMove) + target.addEventListener('pointerup', onUp) + target.addEventListener('pointercancel', onUp) + } + function handleConsider(e: CustomEvent>) { isDragging = true dndMiddle = e.detail.items @@ -100,7 +180,7 @@ function tabClasses(isActive: boolean) { return twMerge( - 'group relative inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + 'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', isActive ? 'bg-surface-tertiary text-emphasis' : 'bg-transparent text-hint hover:text-secondary' @@ -189,41 +269,69 @@ {/snippet} -
- - -
- {#each pinnedLeft as tab (tab.id)} - {@render tabButton(tab)} - {/each} - -
- {#each dndMiddle as tab (tab.id)} -
- {@render tabButton(tab)} -
+
+ +
+
scrollEl && (scrollLeft = scrollEl.scrollLeft)} + class="h-full overflow-x-auto overflow-y-hidden no-scrollbar pl-1" + > + +
+ {#each pinnedLeft as tab (tab.id)} + {@render tabButton(tab)} {/each} + +
+ {#each dndMiddle as tab (tab.id)} + +
+ {@render tabButton(tab)} +
+ {/each} +
+ + {#each pinnedRight as tab (tab.id)} + {@render tabButton(tab)} + {/each} + + {#if afterTabs} + {@render afterTabs()} + {/if}
- - {#each pinnedRight as tab (tab.id)} - {@render tabButton(tab)} - {/each} - - {#if afterTabs} - {@render afterTabs()} - {/if}
- + + {#if overflowing} + + + + {/if} +
{#if trailing}
diff --git a/frontend/src/routes/kitchen_sink/+page.svelte b/frontend/src/routes/kitchen_sink/+page.svelte index 4285ee0921..8681fb78da 100644 --- a/frontend/src/routes/kitchen_sink/+page.svelte +++ b/frontend/src/routes/kitchen_sink/+page.svelte @@ -12,8 +12,8 @@ let tab = $state('button') - // Enough tabs to overflow a narrow strip so the shared ScrollableX hover - // scrollbar is exercised: drag to reorder, hover to reveal the 4px thumb. + // Enough tabs to overflow a narrow strip so the strip's own hover scrollbar is + // exercised: drag to reorder, hover to reveal the 4px thumb. let draggableTabs = $state( Array.from({ length: 14 }, (_, i) => ({ id: `t${i}`, label: `Preview tab ${i + 1}` })) ) @@ -200,8 +200,8 @@ That's the full round-trip.`
- DraggableTabs (uses the shared ScrollableX, 4px bar): hover to reveal the - thumb, drag to reorder. + DraggableTabs (draws its own 4px bar on the strip's bottom edge): hover to reveal the thumb, + drag to reorder.