authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-11 16:21:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 20:21:07-03:00
logc9181d81a8f026f39679ef2363c976d581268b35
treea58ebcb70c1b335ba48ce2338bd488c535a28bf2
parent9e8c7206c873fc2e4c042a442abce269c20b5cd8
signature Signed by PGP key B5690EEEBB952194

fix(web): scrolling over the thread minimap scrolls the chat (#17652)


3 files changed, 113 insertions(+), 0 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+21
......@@ -297,6 +297,7 @@ import { TimelineSystemDivider } from "./TimelineSystemDivider";
297297import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";
298298import * as DateTime from "effect/DateTime";
299299import { formatWorkspaceRelativePath } from "../../filePathDisplay";
300import { forwardWheelToTimeline } from "./timelineMinimapWheel";
300301import {
301302 buildReviewCommentRenderablePatch,
302303 formatReviewCommentFence,
......@@ -1571,6 +1572,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
15711572 [],
15721573 );
15731574
1575 const forwardMinimapWheel = useCallback(
1576 (event: WheelEvent) => {
1577 const scrollNode = listRef.current?.getScrollableNode();
1578 if (scrollNode) forwardWheelToTimeline(event, scrollNode);
1579 },
1580 [listRef],
1581 );
1582
15741583 if (
15751584 rows.length === 0 &&
15761585 !isWorking &&
......@@ -1661,6 +1670,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
16611670 hitStripWidth={minimapHitStripWidth}
16621671 currentIndex={minimapCurrentIndex}
16631672 stripMap={minimapStripMap}
1673 onWheel={forwardMinimapWheel}
16641674 onSelect={(item) => {
16651675 onManualNavigation();
16661676 void listRef.current?.scrollToIndex({
......@@ -1741,6 +1751,7 @@ function TimelineMinimap({
17411751 currentIndex,
17421752 items,
17431753 stripMap,
1754 onWheel,
17441755 onSelect,
17451756}: {
17461757 hasPersistentGutter: boolean;
......@@ -1748,9 +1759,18 @@ function TimelineMinimap({
17481759 currentIndex: number | null;
17491760 items: ReadonlyArray<TimelineMinimapItem>;
17501761 stripMap: Map<string, HTMLSpanElement>;
1762 onWheel: (event: WheelEvent) => void;
17511763 onSelect: (item: TimelineMinimapItem) => void;
17521764}) {
17531765 const [activeIndex, setActiveIndex] = useState<number | null>(null);
1766 const wheelTargetRef = useCallback(
1767 (node: HTMLDivElement | null) => {
1768 if (!node) return;
1769 node.addEventListener("wheel", onWheel, { passive: false });
1770 return () => node.removeEventListener("wheel", onWheel);
1771 },
1772 [onWheel],
1773 );
17541774
17551775 const resolvedActiveIndex =
17561776 activeIndex !== null && activeIndex < items.length ? activeIndex : null;
......@@ -1825,6 +1845,7 @@ function TimelineMinimap({
18251845 )}
18261846 data-testid="timeline-minimap"
18271847 data-persistent-gutter={hasPersistentGutter ? "true" : "false"}
1848 ref={wheelTargetRef}
18281849 >
18291850 <div className="relative h-full w-full select-none">
18301851 <div
apps/web/src/components/chat/timelineMinimapWheel.test.ts created+63
......@@ -0,0 +1,63 @@
1// @vitest-environment jsdom
2
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import { forwardWheelToTimeline } from "./timelineMinimapWheel";
6
7function setup() {
8 const timeline = document.createElement("div");
9 const minimap = document.createElement("div");
10 Object.defineProperty(timeline, "clientHeight", { value: 500 });
11 const scrollBy = vi.fn();
12 timeline.scrollBy = scrollBy as typeof timeline.scrollBy;
13 const seen: WheelEvent[] = [];
14 timeline.addEventListener("wheel", (event) => seen.push(event));
15 minimap.addEventListener("wheel", (event) => forwardWheelToTimeline(event, timeline));
16 document.body.append(timeline, minimap);
17 const wheel = (init: WheelEventInit) => {
18 const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, ...init });
19 minimap.dispatchEvent(event);
20 return event;
21 };
22 return { timeline, scrollBy, seen, wheel };
23}
24
25describe("forwardWheelToTimeline", () => {
26 it("replays the wheel on the timeline and scrolls it instead of the minimap", () => {
27 const { timeline, scrollBy, seen, wheel } = setup();
28 const original = wheel({ deltaY: -120, clientX: 20, clientY: 300 });
29
30 expect(original.defaultPrevented).toBe(true);
31 expect(seen).toHaveLength(1);
32 expect(seen[0]?.target).toBe(timeline);
33 expect(seen[0]?.deltaY).toBe(-120);
34 expect(seen[0]?.clientY).toBe(300);
35 expect(scrollBy).toHaveBeenCalledWith({ top: -120 });
36 });
37
38 it.each([
39 { deltaMode: WheelEvent.DOM_DELTA_LINE, top: 48 },
40 { deltaMode: WheelEvent.DOM_DELTA_PAGE, top: 1500 },
41 ])("converts delta mode $deltaMode to pixels", ({ deltaMode, top }) => {
42 const { scrollBy, wheel } = setup();
43 wheel({ deltaY: 3, deltaMode });
44 expect(scrollBy).toHaveBeenCalledWith({ top });
45 });
46
47 it("lets a timeline listener cancel the scroll", () => {
48 const { timeline, scrollBy, wheel } = setup();
49 timeline.addEventListener("wheel", (event) => event.preventDefault());
50 wheel({ deltaY: 40 });
51 expect(scrollBy).not.toHaveBeenCalled();
52 });
53
54 it.each([{ deltaY: 40, ctrlKey: true }, { deltaX: 40 }])(
55 "leaves pinch zoom and horizontal wheels alone: %j",
56 (init) => {
57 const { scrollBy, seen, wheel } = setup();
58 expect(wheel(init).defaultPrevented).toBe(false);
59 expect(seen).toHaveLength(0);
60 expect(scrollBy).not.toHaveBeenCalled();
61 },
62 );
63});
apps/web/src/components/chat/timelineMinimapWheel.ts created+29
......@@ -0,0 +1,29 @@
1export function forwardWheelToTimeline(event: WheelEvent, timeline: HTMLElement): void {
2 if (event.defaultPrevented || event.ctrlKey || event.deltaY === 0) return;
3 event.preventDefault();
4
5 const forwarded = new WheelEvent("wheel", {
6 bubbles: true,
7 cancelable: true,
8 deltaX: event.deltaX,
9 deltaY: event.deltaY,
10 deltaZ: event.deltaZ,
11 deltaMode: event.deltaMode,
12 clientX: event.clientX,
13 clientY: event.clientY,
14 screenX: event.screenX,
15 screenY: event.screenY,
16 altKey: event.altKey,
17 metaKey: event.metaKey,
18 shiftKey: event.shiftKey,
19 });
20 if (!timeline.dispatchEvent(forwarded)) return;
21
22 const pixelsPerUnit =
23 event.deltaMode === WheelEvent.DOM_DELTA_LINE
24 ? 16
25 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
26 ? timeline.clientHeight
27 : 1;
28 timeline.scrollBy({ top: event.deltaY * pixelsPerUnit });
29}