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";...@@ -297,6 +297,7 @@ import { TimelineSystemDivider } from "./TimelineSystemDivider";
297import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";297import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";
298import * as DateTime from "effect/DateTime";298import * as DateTime from "effect/DateTime";
299import { formatWorkspaceRelativePath } from "../../filePathDisplay";299import { formatWorkspaceRelativePath } from "../../filePathDisplay";
300import { forwardWheelToTimeline } from "./timelineMinimapWheel";
300import {301import {
301 buildReviewCommentRenderablePatch,302 buildReviewCommentRenderablePatch,
302 formatReviewCommentFence,303 formatReviewCommentFence,
...@@ -1571,6 +1572,14 @@ const ConversationTimeline = memo(function ConversationTimeline({...@@ -1571,6 +1572,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
1571 [],1572 [],
1572 );1573 );
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
1574 if (1583 if (
1575 rows.length === 0 &&1584 rows.length === 0 &&
1576 !isWorking &&1585 !isWorking &&
...@@ -1661,6 +1670,7 @@ const ConversationTimeline = memo(function ConversationTimeline({...@@ -1661,6 +1670,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
1661 hitStripWidth={minimapHitStripWidth}1670 hitStripWidth={minimapHitStripWidth}
1662 currentIndex={minimapCurrentIndex}1671 currentIndex={minimapCurrentIndex}
1663 stripMap={minimapStripMap}1672 stripMap={minimapStripMap}
1673 onWheel={forwardMinimapWheel}
1664 onSelect={(item) => {1674 onSelect={(item) => {
1665 onManualNavigation();1675 onManualNavigation();
1666 void listRef.current?.scrollToIndex({1676 void listRef.current?.scrollToIndex({
...@@ -1741,6 +1751,7 @@ function TimelineMinimap({...@@ -1741,6 +1751,7 @@ function TimelineMinimap({
1741 currentIndex,1751 currentIndex,
1742 items,1752 items,
1743 stripMap,1753 stripMap,
1754 onWheel,
1744 onSelect,1755 onSelect,
1745}: {1756}: {
1746 hasPersistentGutter: boolean;1757 hasPersistentGutter: boolean;
...@@ -1748,9 +1759,18 @@ function TimelineMinimap({...@@ -1748,9 +1759,18 @@ function TimelineMinimap({
1748 currentIndex: number | null;1759 currentIndex: number | null;
1749 items: ReadonlyArray<TimelineMinimapItem>;1760 items: ReadonlyArray<TimelineMinimapItem>;
1750 stripMap: Map<string, HTMLSpanElement>;1761 stripMap: Map<string, HTMLSpanElement>;
1762 onWheel: (event: WheelEvent) => void;
1751 onSelect: (item: TimelineMinimapItem) => void;1763 onSelect: (item: TimelineMinimapItem) => void;
1752}) {1764}) {
1753 const [activeIndex, setActiveIndex] = useState<number | null>(null);1765 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
1755 const resolvedActiveIndex =1775 const resolvedActiveIndex =
1756 activeIndex !== null && activeIndex < items.length ? activeIndex : null;1776 activeIndex !== null && activeIndex < items.length ? activeIndex : null;
...@@ -1825,6 +1845,7 @@ function TimelineMinimap({...@@ -1825,6 +1845,7 @@ function TimelineMinimap({
1825 )}1845 )}
1826 data-testid="timeline-minimap"1846 data-testid="timeline-minimap"
1827 data-persistent-gutter={hasPersistentGutter ? "true" : "false"}1847 data-persistent-gutter={hasPersistentGutter ? "true" : "false"}
1848 ref={wheelTargetRef}
1828 >1849 >
1829 <div className="relative h-full w-full select-none">1850 <div className="relative h-full w-full select-none">
1830 <div1851 <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}