diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ad52b716aa212ea4504a93667e077d706fdb0238..5cbc502e5a3c365a96a5cc8a17f9a7545a91cb07 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -297,6 +297,7 @@ import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { SkillChipIcon, SkillInlineText } from "./SkillInlineText"; import * as DateTime from "effect/DateTime"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { forwardWheelToTimeline } from "./timelineMinimapWheel"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -1571,6 +1572,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ [], ); + const forwardMinimapWheel = useCallback( + (event: WheelEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if (scrollNode) forwardWheelToTimeline(event, scrollNode); + }, + [listRef], + ); + if ( rows.length === 0 && !isWorking && @@ -1661,6 +1670,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ hitStripWidth={minimapHitStripWidth} currentIndex={minimapCurrentIndex} stripMap={minimapStripMap} + onWheel={forwardMinimapWheel} onSelect={(item) => { onManualNavigation(); void listRef.current?.scrollToIndex({ @@ -1741,6 +1751,7 @@ function TimelineMinimap({ currentIndex, items, stripMap, + onWheel, onSelect, }: { hasPersistentGutter: boolean; @@ -1748,9 +1759,18 @@ function TimelineMinimap({ currentIndex: number | null; items: ReadonlyArray; stripMap: Map; + onWheel: (event: WheelEvent) => void; onSelect: (item: TimelineMinimapItem) => void; }) { const [activeIndex, setActiveIndex] = useState(null); + const wheelTargetRef = useCallback( + (node: HTMLDivElement | null) => { + if (!node) return; + node.addEventListener("wheel", onWheel, { passive: false }); + return () => node.removeEventListener("wheel", onWheel); + }, + [onWheel], + ); const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; @@ -1825,6 +1845,7 @@ function TimelineMinimap({ )} data-testid="timeline-minimap" data-persistent-gutter={hasPersistentGutter ? "true" : "false"} + ref={wheelTargetRef} >
seen.push(event)); + minimap.addEventListener("wheel", (event) => forwardWheelToTimeline(event, timeline)); + document.body.append(timeline, minimap); + const wheel = (init: WheelEventInit) => { + const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, ...init }); + minimap.dispatchEvent(event); + return event; + }; + return { timeline, scrollBy, seen, wheel }; +} + +describe("forwardWheelToTimeline", () => { + it("replays the wheel on the timeline and scrolls it instead of the minimap", () => { + const { timeline, scrollBy, seen, wheel } = setup(); + const original = wheel({ deltaY: -120, clientX: 20, clientY: 300 }); + + expect(original.defaultPrevented).toBe(true); + expect(seen).toHaveLength(1); + expect(seen[0]?.target).toBe(timeline); + expect(seen[0]?.deltaY).toBe(-120); + expect(seen[0]?.clientY).toBe(300); + expect(scrollBy).toHaveBeenCalledWith({ top: -120 }); + }); + + it.each([ + { deltaMode: WheelEvent.DOM_DELTA_LINE, top: 48 }, + { deltaMode: WheelEvent.DOM_DELTA_PAGE, top: 1500 }, + ])("converts delta mode $deltaMode to pixels", ({ deltaMode, top }) => { + const { scrollBy, wheel } = setup(); + wheel({ deltaY: 3, deltaMode }); + expect(scrollBy).toHaveBeenCalledWith({ top }); + }); + + it("lets a timeline listener cancel the scroll", () => { + const { timeline, scrollBy, wheel } = setup(); + timeline.addEventListener("wheel", (event) => event.preventDefault()); + wheel({ deltaY: 40 }); + expect(scrollBy).not.toHaveBeenCalled(); + }); + + it.each([{ deltaY: 40, ctrlKey: true }, { deltaX: 40 }])( + "leaves pinch zoom and horizontal wheels alone: %j", + (init) => { + const { scrollBy, seen, wheel } = setup(); + expect(wheel(init).defaultPrevented).toBe(false); + expect(seen).toHaveLength(0); + expect(scrollBy).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/src/components/chat/timelineMinimapWheel.ts b/apps/web/src/components/chat/timelineMinimapWheel.ts new file mode 100644 index 0000000000000000000000000000000000000000..94cbd8ce008ffdd502a1c03340745175b677dc81 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapWheel.ts @@ -0,0 +1,29 @@ +export function forwardWheelToTimeline(event: WheelEvent, timeline: HTMLElement): void { + if (event.defaultPrevented || event.ctrlKey || event.deltaY === 0) return; + event.preventDefault(); + + const forwarded = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + screenX: event.screenX, + screenY: event.screenY, + altKey: event.altKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }); + if (!timeline.dispatchEvent(forwarded)) return; + + const pixelsPerUnit = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? timeline.clientHeight + : 1; + timeline.scrollBy({ top: event.deltaY * pixelsPerUnit }); +}