From 41bb7b96d2cfa668befb63c3c7fbb713782da014 Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Thu, 24 Sep 2026 13:44:29 +0200 Subject: [PATCH 1/2] fix(web): latch wheel runs to their first scroller Chromium keeps a run of wheel events on the scroller it started on, so a tool group that reaches its top mid-run swallows the rest of the run. Decide the timeline target once per run instead of once per event. --- apps/web/src/components/ChatView.tsx | 10 ++- apps/web/src/components/chat/ChatComposer.tsx | 5 +- .../chat/timelineScrollTarget.test.ts | 67 +++++++++++++++++-- .../components/chat/timelineScrollTarget.ts | 31 +++++++++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9af410f5722e..28b03bd50950 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -365,7 +365,11 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; -import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; +import { + createTimelineWheelLatch, + isTimelineScrollTarget, + latchTimelineWheelTarget, +} from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -5463,11 +5467,11 @@ export default function ChatView(props: ChatViewProps) { // otherwise break follow with no scroll event left to re-arm it. const viewportIsAwayFromEnd = () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; + const wheelLatch = createTimelineWheelLatch(); // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; + if (event.ctrlKey || !latchTimelineWheelTarget(wheelLatch, event, scrollNode)) return; if (event.deltaY > 0) { timelineScrollIntentRef.current = "toward-end"; if (isAtEndRef.current) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 15440c577ef3..5a0f4337ff62 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -310,7 +310,7 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { pendingDraftWork } from "./pendingDraftWork"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; +import { createTimelineWheelLatch, latchTimelineWheelTarget } from "./timelineScrollTarget"; import { createComposerScrollGestureState, recordComposerScrollGestureEvent, @@ -4881,6 +4881,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerScrollCollapseTimeoutRef.current = null; resetComposerScrollGesture(composerScrollGestureRef.current); }; + const wheelLatch = createTimelineWheelLatch(); const handleTimelineWheel = (event: WheelEvent) => { if (event.ctrlKey || !(event.target instanceof Element)) { return; @@ -4888,7 +4889,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const scrollNode = getTimelineScrollableNode(); if (!scrollNode) return; - const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); + const targetsTimeline = latchTimelineWheelTarget(wheelLatch, event, scrollNode); if ( !scrollNode.contains(event.target) && !composerScrollGestureRef.current.collapseSuppressed diff --git a/apps/web/src/components/chat/timelineScrollTarget.test.ts b/apps/web/src/components/chat/timelineScrollTarget.test.ts index 051f7785f5a3..c515b4bcda11 100644 --- a/apps/web/src/components/chat/timelineScrollTarget.test.ts +++ b/apps/web/src/components/chat/timelineScrollTarget.test.ts @@ -4,7 +4,12 @@ import { createComposerScrollGestureState, recordComposerScrollGestureEvent, } from "./composerScrollGesture"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; +import { + TIMELINE_WHEEL_RUN_GAP_MS, + createTimelineWheelLatch, + isTimelineScrollTarget, + latchTimelineWheelTarget, +} from "./timelineScrollTarget"; class ScrollElement extends EventTarget { scrollTop = 0; @@ -28,6 +33,20 @@ function targetsTimeline(target: EventTarget | null, timeline: ScrollElement, de return isTimelineScrollTarget(target, timeline as unknown as HTMLElement, deltaY); } +function latchedTargetsTimeline( + latch: ReturnType, + target: EventTarget | null, + timeline: ScrollElement, + deltaY: number, + timeStamp: number, +) { + return latchTimelineWheelTarget( + latch, + { target, deltaY, timeStamp }, + timeline as unknown as HTMLElement, + ); +} + function setup() { const timeline = Object.assign(new ScrollElement(), { overflowY: "auto", @@ -122,21 +141,59 @@ describe("timeline scroll targets", () => { it("does not accumulate nested scrolling toward composer collapse", () => { const { timeline, group, content } = setup(); + const latch = createTimelineWheelLatch(); const state = createComposerScrollGestureState(); const record = (target: ScrollElement, now: number, deltaPx: number) => recordComposerScrollGestureEvent(state, { now, deltaPx, collapseThresholdPx: 24, - collapseEligible: targetsTimeline(target, timeline, -deltaPx), + collapseEligible: latchedTargetsTimeline(latch, target, timeline, -deltaPx, now), canScrollInGestureDirection: timeline.scrollTop > 0, scrollsTowardLogicalEnd: false, }); - expect(record(timeline, 0, 20)).toBe(false); + expect(record(content, 0, 30)).toBe(false); + group.scrollTop = 0; expect(record(content, 20, 30)).toBe(false); + expect(record(content, 40, 30)).toBe(false); + expect(record(content, 40 + TIMELINE_WHEEL_RUN_GAP_MS + 1, 30)).toBe(true); + }); +}); + +describe("timeline wheel runs", () => { + it("keeps a run on a nested group after the group reaches its edge", () => { + const { timeline, group, content } = setup(); + const latch = createTimelineWheelLatch(); + + expect(latchedTargetsTimeline(latch, content, timeline, -100, 0)).toBe(false); + group.scrollTop = 0; + // A slow wheel still counts as one run while clicks keep arriving. + expect(latchedTargetsTimeline(latch, content, timeline, -100, 400)).toBe(false); + expect(latchedTargetsTimeline(latch, content, timeline, -100, 800)).toBe(false); + expect( + latchedTargetsTimeline(latch, content, timeline, -100, 800 + TIMELINE_WHEEL_RUN_GAP_MS + 1), + ).toBe(true); + }); + + it("keeps a timeline run on the timeline when a nested group slides under the pointer", () => { + const { timeline, content } = setup(); + const latch = createTimelineWheelLatch(); + + expect(latchedTargetsTimeline(latch, new ScrollElement(timeline), timeline, -100, 0)).toBe( + true, + ); + expect(latchedTargetsTimeline(latch, content, timeline, -100, 30)).toBe(true); + }); + + it("does not let a horizontal-only event decide or end a run", () => { + const { timeline, group, content } = setup(); + const latch = createTimelineWheelLatch(); + + expect(latchedTargetsTimeline(latch, content, timeline, 0, 0)).toBe(false); group.scrollTop = 0; - expect(record(content, 40, 10)).toBe(false); - expect(record(content, 60, 14)).toBe(true); + expect(latchedTargetsTimeline(latch, content, timeline, -100, 10)).toBe(true); + expect(latchedTargetsTimeline(latch, content, timeline, 0, 20)).toBe(false); + expect(latchedTargetsTimeline(latch, content, timeline, -100, 30)).toBe(true); }); }); diff --git a/apps/web/src/components/chat/timelineScrollTarget.ts b/apps/web/src/components/chat/timelineScrollTarget.ts index da87e9a7036d..c1847a75152d 100644 --- a/apps/web/src/components/chat/timelineScrollTarget.ts +++ b/apps/web/src/components/chat/timelineScrollTarget.ts @@ -29,3 +29,34 @@ export function isTimelineScrollTarget( } return true; } + +// Chromium keeps a run of wheel events on the scroller the run started on: a +// nested group that reaches its edge mid-run swallows the rest of the run, and +// the timeline only moves on a run that starts at that edge. With OS wheel input +// in Edge, clicks 400 ms apart stayed on the group and 800 ms apart chained; +// Chromium's own wheel transaction timeout is 500 ms. +export const TIMELINE_WHEEL_RUN_GAP_MS = 500; + +export type TimelineWheelLatch = { + targetsTimeline: boolean; + lastEventAt: number; +}; + +export function createTimelineWheelLatch(): TimelineWheelLatch { + return { targetsTimeline: false, lastEventAt: Number.NEGATIVE_INFINITY }; +} + +// Whether a wheel event scrolls the timeline, decided once per run by +// isTimelineScrollTarget on the run's first vertical event. +export function latchTimelineWheelTarget( + latch: TimelineWheelLatch, + event: Pick, + timeline: HTMLElement, +): boolean { + if (event.deltaY === 0) return false; + if (event.timeStamp - latch.lastEventAt > TIMELINE_WHEEL_RUN_GAP_MS) { + latch.targetsTimeline = isTimelineScrollTarget(event.target, timeline, event.deltaY); + } + latch.lastEventAt = event.timeStamp; + return latch.targetsTimeline; +} From dfb24ac84fce2b0e23820f3addaba4e38ee8e090 Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Thu, 24 Sep 2026 13:58:40 +0200 Subject: [PATCH 2/2] fix(web): keep the wheel latch across reattaches ChatView's listener effect reattaches when the composer inset changes, which dropped its latch mid-run; keep it in a ref reset per thread. The composer's document listener no longer lets events outside the timeline start or extend a run. --- apps/web/src/components/ChatView.tsx | 10 ++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 12 ++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 28b03bd50950..e22c8c2844bd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5435,6 +5435,12 @@ export default function ChatView(props: ChatViewProps) { timelineEntries, timelineLiveFollowEnabled, ]); + // Outlives the listener effect below, which reattaches whenever the + // composer inset changes, possibly in the middle of a wheel run. + const wheelLatchRef = useRef(createTimelineWheelLatch()); + useEffect(() => { + wheelLatchRef.current = createTimelineWheelLatch(); + }, [activeThread?.id]); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -5467,11 +5473,11 @@ export default function ChatView(props: ChatViewProps) { // otherwise break follow with no scroll event left to re-arm it. const viewportIsAwayFromEnd = () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const wheelLatch = createTimelineWheelLatch(); // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !latchTimelineWheelTarget(wheelLatch, event, scrollNode)) return; + if (event.ctrlKey || !latchTimelineWheelTarget(wheelLatchRef.current, event, scrollNode)) + return; if (event.deltaY > 0) { timelineScrollIntentRef.current = "toward-end"; if (isAtEndRef.current) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5a0f4337ff62..cf1ae9e585f5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4889,12 +4889,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const scrollNode = getTimelineScrollableNode(); if (!scrollNode) return; - const targetsTimeline = latchTimelineWheelTarget(wheelLatch, event, scrollNode); - if ( - !scrollNode.contains(event.target) && - !composerScrollGestureRef.current.collapseSuppressed - ) - return; + // Only timeline events may start or extend a run, as in ChatView, whose + // listener sits on the timeline itself. + const insideTimeline = scrollNode.contains(event.target); + if (!insideTimeline && !composerScrollGestureRef.current.collapseSuppressed) return; + const targetsTimeline = + insideTimeline && latchTimelineWheelTarget(wheelLatch, event, scrollNode); if (composerScrollCollapseTimeoutRef.current !== null) { window.clearTimeout(composerScrollCollapseTimeoutRef.current);