| author | |
| committer | |
| log | c9181d81a8f026f39679ef2363c976d581268b35 |
| tree | a58ebcb70c1b335ba48ce2338bd488c535a28bf2 |
| parent | 9e8c7206c873fc2e4c042a442abce269c20b5cd8 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 113 insertions(+), 0 deletions(-)
apps/web/src/components/chat/MessagesTimeline.tsx+21| ... | ... | @@ -297,6 +297,7 @@ import { TimelineSystemDivider } from "./TimelineSystemDivider"; |
| 297 | 297 | import { SkillChipIcon, SkillInlineText } from "./SkillInlineText"; |
| 298 | 298 | import * as DateTime from "effect/DateTime"; |
| 299 | 299 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 300 | import { forwardWheelToTimeline } from "./timelineMinimapWheel"; | |
| 300 | 301 | import { |
| 301 | 302 | buildReviewCommentRenderablePatch, |
| 302 | 303 | formatReviewCommentFence, |
| ... | ... | @@ -1571,6 +1572,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1571 | 1572 | [], |
| 1572 | 1573 | ); |
| 1573 | 1574 | |
| 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 | 1583 | if ( |
| 1575 | 1584 | rows.length === 0 && |
| 1576 | 1585 | !isWorking && |
| ... | ... | @@ -1661,6 +1670,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1661 | 1670 | hitStripWidth={minimapHitStripWidth} |
| 1662 | 1671 | currentIndex={minimapCurrentIndex} |
| 1663 | 1672 | stripMap={minimapStripMap} |
| 1673 | onWheel={forwardMinimapWheel} | |
| 1664 | 1674 | onSelect={(item) => { |
| 1665 | 1675 | onManualNavigation(); |
| 1666 | 1676 | void listRef.current?.scrollToIndex({ |
| ... | ... | @@ -1741,6 +1751,7 @@ function TimelineMinimap({ |
| 1741 | 1751 | currentIndex, |
| 1742 | 1752 | items, |
| 1743 | 1753 | stripMap, |
| 1754 | onWheel, | |
| 1744 | 1755 | onSelect, |
| 1745 | 1756 | }: { |
| 1746 | 1757 | hasPersistentGutter: boolean; |
| ... | ... | @@ -1748,9 +1759,18 @@ function TimelineMinimap({ |
| 1748 | 1759 | currentIndex: number | null; |
| 1749 | 1760 | items: ReadonlyArray<TimelineMinimapItem>; |
| 1750 | 1761 | stripMap: Map<string, HTMLSpanElement>; |
| 1762 | onWheel: (event: WheelEvent) => void; | |
| 1751 | 1763 | onSelect: (item: TimelineMinimapItem) => void; |
| 1752 | 1764 | }) { |
| 1753 | 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 | ); | |
| 1754 | 1774 | |
| 1755 | 1775 | const resolvedActiveIndex = |
| 1756 | 1776 | activeIndex !== null && activeIndex < items.length ? activeIndex : null; |
| ... | ... | @@ -1825,6 +1845,7 @@ function TimelineMinimap({ |
| 1825 | 1845 | )} |
| 1826 | 1846 | data-testid="timeline-minimap" |
| 1827 | 1847 | data-persistent-gutter={hasPersistentGutter ? "true" : "false"} |
| 1848 | ref={wheelTargetRef} | |
| 1828 | 1849 | > |
| 1829 | 1850 | <div className="relative h-full w-full select-none"> |
| 1830 | 1851 | <div |
apps/web/src/components/chat/timelineMinimapWheel.test.ts created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | ||
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { forwardWheelToTimeline } from "./timelineMinimapWheel"; | |
| 6 | ||
| 7 | function 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 | ||
| 25 | describe("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 @@ |
| 1 | export 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 | } |