From 1174bde9826a379ab13869fd148a16345e415a70 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 17 Aug 2026 11:36:14 -0500 Subject: [PATCH] fix(web): navigate timeline minimap with the mouse wheel - Move one message per wheel direction with boundary clamping - Add focused logic coverage for wheel navigation --- .../chat/MessagesTimeline.logic.test.ts | 26 ++++++++ .../components/chat/MessagesTimeline.logic.ts | 19 ++++++ .../src/components/chat/MessagesTimeline.tsx | 64 +++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 70a330d46303..ca1b7928ceee 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5,9 +5,35 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + resolveTimelineMinimapWheelIndex, shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("resolveTimelineMinimapWheelIndex", () => { + it("moves exactly one message in the wheel direction", () => { + expect(resolveTimelineMinimapWheelIndex({ currentIndex: 2, itemCount: 5, deltaY: 120 })).toBe( + 3, + ); + expect(resolveTimelineMinimapWheelIndex({ currentIndex: 2, itemCount: 5, deltaY: -120 })).toBe( + 1, + ); + }); + + it("clamps at the first and last message", () => { + expect(resolveTimelineMinimapWheelIndex({ currentIndex: 0, itemCount: 5, deltaY: -1 })).toBe(0); + expect(resolveTimelineMinimapWheelIndex({ currentIndex: 4, itemCount: 5, deltaY: 1 })).toBe(4); + }); + + it("ignores wheel events that cannot express vertical navigation", () => { + expect( + resolveTimelineMinimapWheelIndex({ currentIndex: 2, itemCount: 5, deltaY: 0 }), + ).toBeNull(); + expect( + resolveTimelineMinimapWheelIndex({ currentIndex: 2, itemCount: 0, deltaY: 1 }), + ).toBeNull(); + }); +}); + describe("shouldPreserveAssistantLineBreaks", () => { it("preserves Claude insight formatting without changing regular markdown", () => { expect( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c89bbd0557d9..7f954b3e8882 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -85,6 +85,25 @@ export function resolveTimelineMinimapIndexFromPointer(input: { return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))); } +export function resolveTimelineMinimapWheelIndex(input: { + readonly currentIndex: number; + readonly itemCount: number; + readonly deltaY: number; +}): number | null { + if ( + input.itemCount <= 0 || + !Number.isFinite(input.currentIndex) || + !Number.isFinite(input.deltaY) || + input.deltaY === 0 + ) { + return null; + } + + const currentIndex = Math.max(0, Math.min(input.itemCount - 1, Math.round(input.currentIndex))); + const direction = input.deltaY > 0 ? 1 : -1; + return Math.max(0, Math.min(input.itemCount - 1, currentIndex + direction)); +} + export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean { if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { return false; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 90f97be892c1..810fca389bcd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -84,6 +84,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + resolveTimelineMinimapWheelIndex, shouldPreserveAssistantLineBreaks, type StableMessagesTimelineRowsState, type MessagesTimelineRow, @@ -729,6 +730,8 @@ function TimelineMinimap({ onSelect: (item: TimelineMinimapItem) => void; }) { const [activeIndex, setActiveIndex] = useState(null); + const minimapButtonRef = useRef(null); + const wheelIndexRef = useRef(null); const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; @@ -746,6 +749,10 @@ function TimelineMinimap({ ? "-100%" : "-50%"; + useEffect(() => { + wheelIndexRef.current = resolvedActiveIndex; + }, [resolvedActiveIndex]); + const resolveActiveIndexFromPointer = useCallback( (event: MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); @@ -763,10 +770,66 @@ function TimelineMinimap({ (event: MouseEvent) => { const nextIndex = resolveActiveIndexFromPointer(event); setActiveIndex(nextIndex); + wheelIndexRef.current = nextIndex; }, [resolveActiveIndexFromPointer], ); + const navigateFromWheel = useCallback( + (event: WheelEvent) => { + if (event.deltaY === 0) { + return; + } + + const button = minimapButtonRef.current; + if (!button) { + return; + } + + const rect = button.getBoundingClientRect(); + const pointerIndex = resolveTimelineMinimapIndexFromPointer({ + itemCount: items.length, + railTop: rect.top, + railHeight: rect.height, + pointerY: event.clientY, + }); + const currentIndex = wheelIndexRef.current ?? pointerIndex; + if (currentIndex === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const nextIndex = resolveTimelineMinimapWheelIndex({ + currentIndex, + itemCount: items.length, + deltaY: event.deltaY, + }); + const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); + if (nextIndex === null || nextIndex === currentIndex || nextItem === null) { + return; + } + + setActiveIndex(nextIndex); + wheelIndexRef.current = nextIndex; + onSelect(nextItem); + }, + [items, onSelect], + ); + + useEffect(() => { + const button = minimapButtonRef.current; + if (!button) { + return; + } + + button.addEventListener("wheel", navigateFromWheel, { passive: false }); + return () => { + button.removeEventListener("wheel", navigateFromWheel); + }; + }, [navigateFromWheel]); + const moveActiveIndex = useCallback( (delta: number) => { setActiveIndex((current) => { @@ -794,6 +857,7 @@ function TimelineMinimap({ >