From bbf12b4bc4bc2fd1c8701d8cd18c3f0409b1fb88 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Nov 2026 08:10:05 +0000 Subject: [PATCH 1/4] test(web): reproduce #28558 diagonal-start vertical scroll navigating history Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index b2e6fcaaaf402..f117ffb42bfb6 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -58,4 +58,18 @@ describe("HistorySwipeNavigation", () => { expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0); }); + + it("a vertical scroll that started slightly diagonal and drifts sideways keeps the thread", async () => { + navigateHistory.mockReset(); + await using screen = await render(); + const content = screen.container.appendChild(document.createElement("div")); + const wheel = (deltaX: number, deltaY: number) => + content.dispatchEvent(new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })); + + for (let index = 0; index < 2; index++) wheel(-4, -3); + for (let index = 0; index < 40; index++) wheel(-3, -25); + wheel(0, 0); + + expect(navigateHistory).not.toHaveBeenCalled(); + }); }); From 6cc8af295861b5f900d97cb3ce1f24f20842a333 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Nov 2026 08:13:42 +0000 Subject: [PATCH 2/4] fix(web): vertical scrolls with a diagonal start no longer navigate history The swipe axis was locked from the first 8px of movement, and tracking then counted only horizontal deltas. A conversation scroll that started slightly diagonal and drifted sideways reached the trigger distance and went back. Tracking now also sums absolute horizontal and vertical travel, starting from the lock movement, and drops the gesture (hiding the indicator) once vertical travel exceeds horizontal. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/HistorySwipeNavigation.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 767b4285c696f..5a109c5850063 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -17,6 +17,8 @@ type SwipeGesture = direction: HistoryDirection; distance: number; deltas: number[]; + travelX: number; + travelY: number; y: number; bounds: SwipeBounds; } @@ -68,6 +70,8 @@ function lockSwipeAxis( direction, distance: Math.abs(sumX), deltas: [], + travelX: Math.abs(sumX), + travelY: Math.abs(sumY), y: pending.y, bounds: readMainContentBounds(), }; @@ -130,6 +134,13 @@ export function HistorySwipeNavigation() { } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; if (event.deltaX === 0 && event.deltaY === 0) return release(); + gesture.travelX += Math.abs(event.deltaX); + gesture.travelY += Math.abs(event.deltaY); + // A scroll that locked on a diagonal start can turn out to be vertical. + if (gesture.travelY > gesture.travelX) { + gesture = { kind: "ignored" }; + return hideIndicator(); + } gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; if (isMomentumTail(gesture.deltas)) return release(); gesture.distance = Math.max(0, gesture.distance + delta); From 27e8b4f8dff6d97595ebb790a0c52c609e4b454c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Nov 2026 09:12:28 +0000 Subject: [PATCH 3/4] fix(web): a swipe that briefly turns vertical can still navigate Dropping the gesture the first time vertical travel led made the cancel permanent, so a back swipe with a small vertical wobble at the start never navigated. The gesture now keeps tracking: while vertical travel leads the indicator hides, and release navigates only when horizontal travel leads and the swipe went far enough. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 15 +++++++++++++++ .../web/src/components/HistorySwipeNavigation.tsx | 13 +++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index f117ffb42bfb6..1edd2c591627a 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -72,4 +72,19 @@ describe("HistorySwipeNavigation", () => { expect(navigateHistory).not.toHaveBeenCalled(); }); + + it("a swipe that wobbles vertically at the start still navigates", async () => { + navigateHistory.mockReset(); + await using screen = await render(); + const content = screen.container.appendChild(document.createElement("div")); + const wheel = (deltaX: number, deltaY: number) => + content.dispatchEvent(new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })); + + wheel(-5, -4); + wheel(0, -2); + for (let index = 0; index < 4; index++) wheel(-40, 0); + wheel(0, 0); + + expect(navigateHistory).toHaveBeenCalledWith("back"); + }); }); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 5a109c5850063..c316c1e3e524c 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -103,7 +103,11 @@ export function HistorySwipeNavigation() { setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. const release = () => { - if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { + if ( + gesture?.kind === "tracking" && + gesture.travelX >= gesture.travelY && + gesture.distance >= SWIPE_TRIGGER_DISTANCE + ) { navigateHistory(gesture.direction); } gesture = { kind: "ignored" }; @@ -136,14 +140,11 @@ export function HistorySwipeNavigation() { if (event.deltaX === 0 && event.deltaY === 0) return release(); gesture.travelX += Math.abs(event.deltaX); gesture.travelY += Math.abs(event.deltaY); - // A scroll that locked on a diagonal start can turn out to be vertical. - if (gesture.travelY > gesture.travelX) { - gesture = { kind: "ignored" }; - return hideIndicator(); - } gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; if (isMomentumTail(gesture.deltas)) return release(); gesture.distance = Math.max(0, gesture.distance + delta); + // A scroll that locked on a diagonal start can turn out to be vertical. + if (gesture.travelY > gesture.travelX) return hideIndicator(); } setSwipe({ direction: gesture.direction, From 672a86d5deb9055d220d67a13c9af65278eae5c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Nov 2026 09:13:55 +0000 Subject: [PATCH 4/4] perf(web): skip the indicator state update when it is already hidden Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/HistorySwipeNavigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c316c1e3e524c..3e7341106dddc 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -100,7 +100,7 @@ export function HistorySwipeNavigation() { useEffect(() => { let gesture: SwipeGesture | null = null; const hideIndicator = () => - setSwipe((current) => (current === null ? null : { ...current, tracking: false })); + setSwipe((current) => (current?.tracking ? { ...current, tracking: false } : current)); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. const release = () => { if (