diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts new file mode 100644 index 00000000..084bd420 --- /dev/null +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; + +import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent'; + +const key = ( + k: string, + modifiers: Partial> = {}, +) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }); + +describe('isFollowReleaseKey', () => { + test('upward navigation keys release follow', () => { + for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true); + expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true); + }); + + test('downward keys, plain space, and modified shortcuts do not', () => { + for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) { + expect(isFollowReleaseKey(key(k))).toBe(false); + } + expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false); + expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false); + expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false); + }); +}); + +// The helpers only use Element#closest, scrollTop, and identity, so a minimal +// DOM stand-in built on EventTarget is enough — no renderer or jsdom. +class FakeElement extends EventTarget { + scrollTop = 0; + constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) { + super(); + } + closest(selector: string): FakeElement | null { + if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`); + if (this.scrollable) return this; + return this.parent?.closest(selector) ?? null; + } +} +// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`; +// registering the fakes under those globals keeps the narrowing honest in bun. +const installDomGlobals = () => { + const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement }; + Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement }); + return () => Object.assign(globalThis, previous); +}; +// With the globals above installed, FakeElement IS the HTMLElement the helpers +// narrow to; reading it back through the global bridges the static type without +// asserting anything the runtime does not hold. +const asRoot = (element: FakeElement): HTMLElement => { + if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed'); + return element; +}; + +describe('nested scroller handling', () => { + test('an upward wheel over a nested scroller with room above stays there', () => { + const restore = installDomGlobals(); + try { + const root = new FakeElement(false); + const box = new FakeElement(true, root); + const inner = new FakeElement(false, box); + box.scrollTop = 40; + expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true); + box.scrollTop = 0; + expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false); + expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false); + } finally { + restore(); + } + }); + + test('a middle-button press pans the timeline unless it lands in a nested scroller', () => { + const restore = installDomGlobals(); + try { + const root = new FakeElement(false); + const row = new FakeElement(false, root); + const box = new FakeElement(true, root); + expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true); + expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false); + expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false); + } finally { + restore(); + } + }); +}); diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.ts new file mode 100644 index 00000000..c6709f8e --- /dev/null +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.ts @@ -0,0 +1,41 @@ +// Gesture classification for the chat timeline's follow opt-out. +// +// The timeline releases live follow on REAL upward gestures only. Wheel and +// touch carry their direction; this module answers the same question for the +// inputs that do not: which keys mean "scroll up", when a middle-button press +// starts a pan, and when an upward wheel belongs to a nested scroller (a tool +// output box) that can still consume it. Pure functions, no DOM ownership, +// so the rules are testable without a renderer. + +// A nested scroller inside the timeline marks itself with this attribute +// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for +// as long as the box has room above. +const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]'; + +export const isFollowReleaseKey = ( + event: Pick, +): boolean => { + // Modified keys are shortcuts, not navigation. + if (event.altKey || event.ctrlKey || event.metaKey) return false; + if (event.key === ' ') return event.shiftKey; + return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home'; +}; + +const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => { + if (!(target instanceof Element)) return null; + const nested = target.closest(NESTED_SCROLLABLE_SELECTOR); + return nested instanceof HTMLElement && nested !== root ? nested : null; +}; + +// An upward wheel over a nested scroller that still has content above stays +// with that scroller; the timeline must not treat it as leaving the end. +export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => { + const nested = nestedScrollable(root, target); + return nested !== null && nested.scrollTop > 0; +}; + +// Middle-button press starts the platform's autoscroll pan (Windows/Linux +// Chromium); the pan then scrolls without wheel events, so the press itself is +// the gesture. Inside a nested scroller the pan belongs to that scroller. +export const isMiddleButtonPan = (root: HTMLElement, event: Pick): boolean => + event.button === 1 && nestedScrollable(root, event.target) === null; diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 9255b1e2..dcccdf37 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -13,6 +13,11 @@ import { type TimelineListMeasurementState, type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; +import { + isFollowReleaseKey, + isMiddleButtonPan, + nestedScrollableConsumesWheelUp, +} from '@/components/chat/lib/scroll/timelineScrollIntent'; // ────────────────────────────────────────────────────────────────────────── // Chat timeline scroll ownership. @@ -772,8 +777,12 @@ export const useChatTimelineScroll = ({ onManualNavigationRef.current(); }; const handleWheel = (event: WheelEvent) => { - // Scrolling toward the end is not opting out of follow. - if (event.deltaY < 0 && canScrollUp()) gesture(); + // Scrolling toward the end is not opting out of follow, and an + // upward wheel that a nested scroller still consumes never + // reaches the timeline. + if (event.deltaY < 0 && !nestedScrollableConsumesWheelUp(scrollNode, event.target) && canScrollUp()) { + gesture(); + } }; // Touch mirrors wheel by finger direction, not by having already left // the end: while a stream keeps re-pinning the viewport, waiting for @@ -797,14 +806,19 @@ export const useChatTimelineScroll = ({ touchLastY = null; }; const handlePointerDown = (event: PointerEvent) => { - // The scrollbar track is the scroll node itself; a tap on a row - // only breaks follow when the viewport already left the end. + // A middle-button pan scrolls without wheel events (and is the + // only scroll gesture for wheel-less mice), so the press is the + // opt-out. Otherwise the scrollbar track is the scroll node + // itself; a tap on a row only breaks follow when the viewport + // already left the end. + if (isMiddleButtonPan(scrollNode, event)) { + if (canScrollUp()) gesture(); + return; + } if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture(); }; const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) { - gesture(); - } + if (isFollowReleaseKey(event) && canScrollUp()) gesture(); }; const handleScroll = () => { queueSave();