From 5625eb994814f09de502ca19d463d6f9ea976f93 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 10:35:12 +0800 Subject: [PATCH 01/11] fix(app): stabilize timeline scroll recovery --- .../session-timeline-scroll-anchors.test.ts | 153 +++++++++++++++++ .../session-timeline-scroll-anchors.ts | 161 +++++++++++++++++- ...session-timeline-scroll-controller.test.ts | 40 ++++- .../session-timeline-scroll-controller.ts | 56 +++++- .../session/use-session-scroll-dock.test.ts | 44 +++++ .../pages/session/use-session-scroll-dock.ts | 41 ++++- .../use-session-timeline-interaction.ts | 18 ++ 7 files changed, 499 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts index a18027c88..77f21dc0a 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -68,6 +68,14 @@ function appendMessage(viewport: HTMLElement, id: string, rect: RectInput) { return message } +function appendTimelineAnchor(parent: HTMLElement, key: string, rect: RectInput) { + const anchor = document.createElement("div") + anchor.dataset.timelineAnchor = key + stubRect(anchor, rect) + parent.appendChild(anchor) + return anchor +} + describe("session timeline scroll anchors", () => { test("collects near-top and near-bottom metrics from explicit geometry", () => { const { viewport } = makeViewport({ @@ -133,6 +141,75 @@ describe("session timeline scroll anchors", () => { }) }) + test("samples the visible timeline anchor nearest the viewport reading line before the message row", () => { + const { viewport } = makeViewport({ + scrollTop: 260, + clientHeight: 400, + scrollHeight: 1600, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(viewport, "msg_anchor", { top: 100, bottom: 900 }) + appendTimelineAnchor(message, "tool:above", { top: 104, bottom: 140 }) + appendTimelineAnchor(message, "trow:stable", { top: 188, bottom: 260 }) + appendTimelineAnchor(message, "tool:below", { top: 360, bottom: 460 }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "reading_history", + renderedStart: 4, + renderedCount: 10, + newestMessageID: "msg_newest", + }), + ).toEqual({ + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 88, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "trow:stable", + offsetFromViewportTop: 88, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 0, + }, + }) + }) + + test("ignores hidden or edge-only timeline anchors while sampling reading position", () => { + const { viewport } = makeViewport({ + scrollTop: 260, + clientHeight: 400, + scrollHeight: 1600, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(viewport, "msg_anchor", { top: 80, bottom: 900 }) + appendTimelineAnchor(message, "tool:edge", { top: 499.5, bottom: 500 }) + appendTimelineAnchor(message, "tool:zero", { top: 220, bottom: 220 }) + const hidden = appendTimelineAnchor(message, "tool:hidden", { top: 180, bottom: 230 }) + hidden.hidden = true + appendTimelineAnchor(message, "tool:visible", { top: 240, bottom: 300 }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "reading_history", + renderedStart: 4, + renderedCount: 10, + newestMessageID: "msg_newest", + }), + ).toEqual( + expect.objectContaining({ + kind: "reading", + anchorMessageID: "msg_anchor", + primaryAnchor: expect.objectContaining({ key: "tool:visible", scope: "tool" }), + }), + ) + }) + test("keeps target message as the sampled anchor while targeting", () => { const { viewport } = makeViewport({ scrollTop: 260, @@ -218,6 +295,82 @@ describe("session timeline scroll anchors", () => { expect(scroller.scrollTop).toBe(456) }) + test("restores reading position using the primary timeline anchor before the message row", () => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(scroller.viewport, "msg_anchor", { top: 160, bottom: 700 }) + appendTimelineAnchor(message, "tool:part:1", { top: 220, bottom: 320 }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 0, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:part:1", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 24, + }, + }, + }), + ).toEqual({ + ok: true, + restoredTo: expect.objectContaining({ + kind: "reading", + primaryAnchor: expect.objectContaining({ key: "tool:part:1" }), + }), + }) + expect(scroller.scrollTop).toBe(448) + }) + + test("falls back to the message row when the primary timeline anchor disappeared", () => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + appendMessage(scroller.viewport, "msg_anchor", { top: 180, bottom: 700 }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 0, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:part:missing", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 24, + }, + }, + }), + ).toEqual({ + ok: true, + restoredTo: expect.objectContaining({ kind: "reading" }), + }) + expect(scroller.scrollTop).toBe(456) + }) + test("restores nearest target only when it is outside the viewport", () => { const scroller = makeViewport({ scrollTop: 100, diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts index 0e407e63b..b9c5d99c5 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -1,4 +1,6 @@ import type { + TimelineReadingAnchor, + TimelineReadingAnchorScope, TimelineSafePosition, TimelineScrollMetrics, TimelineScrollMode, @@ -28,6 +30,125 @@ function firstVisibleMessage(viewport: HTMLElement) { return undefined } +const READING_LINE_OFFSET_PX = 100 +const MIN_VISIBLE_ANCHOR_INTERSECTION_PX = 2 + +function timelineAnchorElements(viewport: HTMLElement) { + return Array.from(viewport.querySelectorAll("[data-timeline-anchor]")).filter( + (el): el is HTMLElement => el instanceof HTMLElement, + ) +} + +function timelineAnchorScope(key: string): TimelineReadingAnchorScope { + if (key.startsWith("tool:")) return "tool" + if (key.startsWith("trow:")) return "trow" + return "message" +} + +function isElementHidden(el: HTMLElement) { + if (el.hidden) return true + const style = el.ownerDocument.defaultView?.getComputedStyle(el) + return style?.display === "none" || style?.visibility === "hidden" +} + +function isInsideClosedDetails(el: HTMLElement) { + let current: HTMLElement | null = el + while (current) { + if (current instanceof HTMLDetailsElement && !current.open) return true + current = current.parentElement + } + return false +} + +function visibleIntersectionPx(rect: DOMRect, viewportRect: DOMRect) { + return Math.max(0, Math.min(rect.bottom, viewportRect.bottom) - Math.max(rect.top, viewportRect.top)) +} + +function isStableVisibleAnchor(el: HTMLElement, rect: DOMRect, viewportRect: DOMRect) { + if (!el.dataset.timelineAnchor) return false + if (isElementHidden(el)) return false + if (isInsideClosedDetails(el)) return false + if (rect.width <= 0 || rect.height <= 0) return false + return visibleIntersectionPx(rect, viewportRect) >= MIN_VISIBLE_ANCHOR_INTERSECTION_PX +} + +function messageElementForAnchor(el: HTMLElement) { + const message = el.closest("[data-message-id]") + return message instanceof HTMLElement ? message : undefined +} + +function timelineAnchorByKey(viewport: HTMLElement, key: string) { + return timelineAnchorElements(viewport).find((el) => el.dataset.timelineAnchor === key) +} + +function makeReadingAnchor(el: HTMLElement, rect: DOMRect, viewportRect: DOMRect): TimelineReadingAnchor | undefined { + const key = el.dataset.timelineAnchor + if (!key) return undefined + return { + key, + offsetFromViewportTop: rect.top - viewportRect.top, + scope: timelineAnchorScope(key), + } +} + +function findFallbackTrowAnchor(input: { + viewport: HTMLElement + selected: HTMLElement + selectedKey: string + viewportRect: DOMRect +}) { + const message = messageElementForAnchor(input.selected) + if (!message) return undefined + for (const candidate of timelineAnchorElements(message)) { + const key = candidate.dataset.timelineAnchor + if (!key || key === input.selectedKey || !key.startsWith("trow:")) continue + const rect = candidate.getBoundingClientRect() + if (!isStableVisibleAnchor(candidate, rect, input.viewportRect)) continue + const anchor = makeReadingAnchor(candidate, rect, input.viewportRect) + if (anchor) return anchor + } +} + +function bestVisibleTimelineAnchor(viewport: HTMLElement) { + const viewportRect = viewport.getBoundingClientRect() + const readingLine = viewportRect.top + READING_LINE_OFFSET_PX + const candidates = timelineAnchorElements(viewport) + .map((el) => ({ el, rect: el.getBoundingClientRect() })) + .filter(({ el, rect }) => isStableVisibleAnchor(el, rect, viewportRect)) + + candidates.sort((a, b) => { + const aDistance = Math.abs(a.rect.top - readingLine) + const bDistance = Math.abs(b.rect.top - readingLine) + if (aDistance !== bDistance) return aDistance - bDistance + return a.rect.top - b.rect.top + }) + + const selected = candidates[0] + if (!selected) return undefined + const primaryAnchor = makeReadingAnchor(selected.el, selected.rect, viewportRect) + if (!primaryAnchor) return undefined + const message = messageElementForAnchor(selected.el) + const messageID = message?.dataset.messageId + if (!message || !messageID) return undefined + const messageRect = message.getBoundingClientRect() + return { + primaryAnchor, + fallbackTrowAnchor: + primaryAnchor.scope === "tool" + ? findFallbackTrowAnchor({ + viewport, + selected: selected.el, + selectedKey: primaryAnchor.key, + viewportRect, + }) + : undefined, + fallbackMessage: { + messageID, + offsetFromViewportTop: messageRect.top - viewportRect.top, + }, + } +} + const fallbackTimelineScrollCommandSink = createTimelineScrollCommandSink() function setTimelineScrollTop(input: { @@ -83,6 +204,20 @@ export function sampleTimelineSafePosition(args: { if (!visible || !messageID) return { kind: "latest", messageID: args.newestMessageID } const viewportRect = args.viewport.getBoundingClientRect() + const timelineAnchor = bestVisibleTimelineAnchor(args.viewport) + if (timelineAnchor) { + return { + kind: "reading", + anchorMessageID: timelineAnchor.fallbackMessage.messageID, + offsetFromViewportTop: timelineAnchor.primaryAnchor.offsetFromViewportTop, + renderedStart: args.renderedStart, + renderedCount: args.renderedCount, + primaryAnchor: timelineAnchor.primaryAnchor, + fallbackTrowAnchor: timelineAnchor.fallbackTrowAnchor, + fallbackMessage: timelineAnchor.fallbackMessage, + } + } + return { kind: "reading", anchorMessageID: messageID, @@ -123,14 +258,34 @@ function restoreReading( position: Extract, sink: TimelineScrollCommandSink, ) { - const anchor = messageElementByID(viewport, position.anchorMessageID) - if (!anchor) return false const viewportRect = viewport.getBoundingClientRect() + const timelineAnchors = [position.primaryAnchor, position.fallbackTrowAnchor].filter( + (anchor): anchor is TimelineReadingAnchor => !!anchor, + ) + + for (const timelineAnchor of timelineAnchors) { + const anchor = timelineAnchorByKey(viewport, timelineAnchor.key) + if (!anchor) continue + const anchorRect = anchor.getBoundingClientRect() + setTimelineScrollTop({ + viewport, + sink, + top: viewport.scrollTop + anchorRect.top - viewportRect.top - timelineAnchor.offsetFromViewportTop, + source: "session-timeline-scroll-anchors/restoreReading", + reason: "reading-timeline-anchor", + }) + return true + } + + const fallbackMessageID = position.fallbackMessage?.messageID ?? position.anchorMessageID + const fallbackOffset = position.fallbackMessage?.offsetFromViewportTop ?? position.offsetFromViewportTop + const anchor = messageElementByID(viewport, fallbackMessageID) + if (!anchor) return false const anchorRect = anchor.getBoundingClientRect() setTimelineScrollTop({ viewport, sink, - top: viewport.scrollTop + anchorRect.top - viewportRect.top - position.offsetFromViewportTop, + top: viewport.scrollTop + anchorRect.top - viewportRect.top - fallbackOffset, source: "session-timeline-scroll-anchors/restoreReading", reason: "reading-anchor", }) diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts index e24fec9f8..e29e802d4 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts @@ -309,7 +309,7 @@ describe("session timeline scroll controller", () => { expect(controller.state().latestProtected).toBe(false) }) - test("weak upward wheel after submit prevents top-reset restore latest", () => { + test("weak upward wheel after submit keeps latest protection during layout settling", () => { const { controller } = makeController() controller.intent({ @@ -329,13 +329,37 @@ describe("session timeline scroll controller", () => { safePosition: readingAnchor, }) - expect(intentResult.reason).toBe("user_upward_navigation") - expect(scrollResult.accepted).toBe(true) - expect(scrollResult.recovery).toEqual({ type: "none" }) - expect(scrollResult.reason).toBe("reading_anchor_preserved") - expect(controller.state().mode).toBe("reading_history") - expect(controller.state().latestProtected).toBe(false) - expect(controller.state().lastSafePosition).toEqual(readingAnchor) + expect(intentResult.reason).toBe("latest_protected_weak_upward_ignored") + expect(intentResult.recovery).toEqual({ type: "none" }) + expect(scrollResult.accepted).toBe(false) + expect(scrollResult.recovery).toEqual({ + type: "restore_latest", + reason: "submit_restore_latest_after_top_reset", + }) + expect(controller.state().mode).toBe("following_latest") + expect(controller.state().latestProtected).toBe(true) + }) + + test("weak upward touch after submit keeps latest protection", () => { + const { controller } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + + const result = controller.intent({ + type: "touch_scroll", + source: "timeline", + direction: "up", + strength: "weak", + nestedScrollable: false, + }) + + expect(result.reason).toBe("latest_protected_weak_upward_ignored") + expect(result.recovery).toEqual({ type: "none" }) + expect(controller.state().mode).toBe("following_latest") + expect(controller.state().latestProtected).toBe(true) }) test("explicit bottom navigation rejoins latest from reading", () => { diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts index bcdee99b1..3550e006e 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -22,6 +22,12 @@ export type TimelineSafePosition = offsetFromViewportTop: number renderedStart: number renderedCount: number + primaryAnchor?: TimelineReadingAnchor + fallbackTrowAnchor?: TimelineReadingAnchor + fallbackMessage?: { + messageID: string + offsetFromViewportTop: number + } } | { kind: "target_message" @@ -31,6 +37,14 @@ export type TimelineSafePosition = loadPolicy: "load_until_visible" | "visible_only" } +export type TimelineReadingAnchorScope = "tool" | "trow" | "message" + +export type TimelineReadingAnchor = { + key: string + offsetFromViewportTop: number + scope: TimelineReadingAnchorScope +} + export type TimelineScrollReason = | "submit_follow_latest" | "submit_restore_latest_after_top_reset" @@ -40,6 +54,7 @@ export type TimelineScrollReason = | "user_upward_navigation" | "strong_downward_navigation" | "weak_scroll_observed" + | "latest_protected_weak_upward_ignored" | "scrollbar_drag_started" | "scrollbar_drag_preserve_reading" | "reading_anchor_preserved" @@ -154,6 +169,9 @@ export type TimelineScrollDiagnosticData = { reason: TimelineScrollReason anchor_kind?: TimelineSafePosition["kind"] anchor_message_id?: string + anchor_scope?: "latest" | TimelineReadingAnchorScope + preserve_strategy?: "latest" | "reading" | "target" + ignored_intent_reason?: TimelineScrollReason submit_origin_mode?: TimelineScrollMode near_top?: boolean near_bottom?: boolean @@ -232,6 +250,20 @@ function anchorMessageID(position: TimelineSafePosition | undefined) { return position.messageID } +function anchorScope(position: TimelineSafePosition | undefined): "latest" | TimelineReadingAnchorScope | undefined { + if (!position) return undefined + if (position.kind === "latest") return "latest" + if (position.kind === "reading") return position.primaryAnchor?.scope ?? "message" + return "message" +} + +function preserveStrategy(position: TimelineSafePosition | undefined): "latest" | "reading" | "target" | undefined { + if (!position) return undefined + if (position.kind === "latest") return "latest" + if (position.kind === "reading") return "reading" + return "target" +} + function diagnosticData(input: { before: TimelineScrollControllerState after: TimelineScrollControllerState @@ -258,6 +290,9 @@ function diagnosticData(input: { reason: input.reason, anchor_kind: anchorKind(anchor), anchor_message_id: anchorMessageID(anchor), + anchor_scope: anchorScope(anchor), + preserve_strategy: preserveStrategy(anchor), + ignored_intent_reason: input.reason === "latest_protected_weak_upward_ignored" ? input.reason : undefined, submit_origin_mode: input.after.submitOriginMode, near_top: metrics?.nearTop, near_bottom: metrics?.nearBottom, @@ -293,6 +328,15 @@ function isExplicitTopIntent(intent: TimelineScrollIntent) { return false } +function isWeakUpwardTimelineIntent(intent: TimelineScrollIntent) { + return ( + (intent.type === "wheel_scroll" || intent.type === "touch_scroll") && + intent.direction === "up" && + intent.strength === "weak" && + !intent.nestedScrollable + ) +} + function isExplicitBottomIntent(intent: TimelineScrollIntent) { if (intent.type === "keyboard_scroll") return intent.key === "End" if (intent.type === "jump_latest" || intent.type === "submit") return true @@ -416,6 +460,16 @@ export function createSessionTimelineScrollController( }) } + if (state.mode === "following_latest" && state.latestProtected && isWeakUpwardTimelineIntent(intent)) { + return result({ + before, + intent, + accepted: true, + recovery: noRecovery, + reason: "latest_protected_weak_upward_ignored", + }) + } + if (isExplicitTopIntent(intent)) { state.mode = "reading_history" state.latestProtected = false @@ -504,7 +558,7 @@ export function createSessionTimelineScrollController( state.latestProtected && observation.metrics.nearTop && !observation.metrics.nearBottom && - !(state.lastIntent && isExplicitTopIntent(state.lastIntent)) + !(state.lastIntent && isExplicitTopIntent(state.lastIntent) && !isWeakUpwardTimelineIntent(state.lastIntent)) ) { return result({ before, diff --git a/packages/app/src/pages/session/use-session-scroll-dock.test.ts b/packages/app/src/pages/session/use-session-scroll-dock.test.ts index ae0e793a3..f50d70adb 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.test.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.test.ts @@ -375,6 +375,50 @@ describe("session scroll dock", () => { }) }) + test("lets interaction override dock shrink preservation using pre-resize metrics", () => { + withResizeObserver((triggerResize) => { + createRoot((dispose) => { + const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height") + const promptDock = makeMeasuredDiv(475) + const scroller = makeScroller({ clientHeight: 400, scrollHeight: 1000, scrollTop: 524 }) + const stickValues: boolean[] = [] + + try { + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => undefined, + clearActiveMessage: () => undefined, + fill: () => undefined, + shouldPreserveLatestForLayoutChange: (event) => { + expect(event.kind).toBe("dock-resize") + if (event.nextDockHeight !== 120) return false + expect(event.previousDockHeight).toBe(475) + expect(event.metrics.distanceFromBottom).toBe(76) + return true + }, + runLayoutTransaction: (event) => { + stickValues.push(event.stickToBottom) + event.mutate() + }, + }) + + scrollDock.setScrollRef(scroller.el) + scrollDock.setPromptDockRef(promptDock.el) + scrollDock.autoScroll.pause() + stickValues.length = 0 + promptDock.setHeight(120) + triggerResize(promptDock.el) + + expect(stickValues).toEqual([true]) + } finally { + dispose() + if (previousDockHeight) + document.documentElement.style.setProperty("--composer-dock-height", previousDockHeight) + else document.documentElement.style.removeProperty("--composer-dock-height") + } + }) + }) + }) + test("keeps dock resize bottom-follow recovery inside the active layout transaction", () => { withResizeObserver((triggerResize) => { createRoot((dispose) => { diff --git a/packages/app/src/pages/session/use-session-scroll-dock.ts b/packages/app/src/pages/session/use-session-scroll-dock.ts index 97279a05c..65d650491 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -108,6 +108,19 @@ export function createSessionScrollDock(input: { }) => void onContentResize?: (event: { scrollTop?: number; distanceFromBottom?: number }) => void runLayoutTransaction?: (input: SessionLayoutTransactionInput) => void + shouldPreserveLatestForLayoutChange?: (event: { + kind: "dock-resize" | "content-resize" + dockKind?: "composer" | "question" | "permission" | "followup" | "revert" | "prompt" + previousDockHeight?: number + nextDockHeight?: number + metrics: { + scrollTop: number + scrollHeight: number + clientHeight: number + distanceFromBottom: number + nearBottom: boolean + } + }) => boolean scrollCommandSink?: TimelineScrollCommandSink }) { const fallbackTimelineScrollCommandSink = createTimelineScrollCommandSink() @@ -222,6 +235,18 @@ export function createSessionScrollDock(input: { scheduleScrollState(el, { recoverBottomLock: false }) } + const collectPreLayoutMetrics = (el: HTMLElement) => { + const max = Math.max(0, el.scrollHeight - el.clientHeight) + const distanceFromBottom = Math.max(0, max - el.scrollTop) + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + distanceFromBottom, + nearBottom: distanceFromBottom <= 2, + } + } + // A non-matching owner means the active lock belongs to an older session path. // Cancel it before it can call followBottom or schedule another scroll sample. const restoreBottomIfLocked = (owner?: string) => { @@ -279,11 +304,14 @@ export function createSessionScrollDock(input: { } if (input.runLayoutTransaction && scroller) { + const metrics = collectPreLayoutMetrics(scroller) + const stickToBottom = + input.shouldPreserveLatestForLayoutChange?.({ kind: "content-resize", metrics }) ?? bottomFollowLockedFor() input.runLayoutTransaction({ kind: "content-resize", source: "use-session-scroll-dock/contentObserver", reason: "content-resize", - stickToBottom: bottomFollowLockedFor(), + stickToBottom, mutate: () => runContentMutation(scheduleTransactionScrollState), restoreLatest: (transactionID) => restoreLatestThroughSink({ @@ -308,7 +336,7 @@ export function createSessionScrollDock(input: { const scrollTop = scroller?.scrollTop const distanceFromBottom = scroller ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop : undefined let layoutTransactionHandled = false - const stickToBottom = scroller + const defaultStickToBottom = scroller ? shouldStickToBottomAfterDockResize({ el: scroller, userScrolled: autoScroll.userScrolled(), @@ -316,6 +344,15 @@ export function createSessionScrollDock(input: { nextDockHeight: next, }) : false + const stickToBottom = scroller + ? (input.shouldPreserveLatestForLayoutChange?.({ + kind: "dock-resize", + dockKind, + previousDockHeight, + nextDockHeight: next, + metrics: collectPreLayoutMetrics(scroller), + }) ?? defaultStickToBottom) + : false const runDockMutation = (options: { forceScrollToBottom: () => void scheduleState: (el: HTMLDivElement) => void diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts index 7212d75ea..3cbaa5272 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -189,6 +189,16 @@ export function createSessionTimelineInteraction(input: { }) let scrollDock!: ReturnType + const latestProtectionBandPx = 120 + const isWeakUpwardTimelineIntent = (intent: TimelineScrollIntent) => + (intent.type === "wheel_scroll" || intent.type === "touch_scroll") && + intent.direction === "up" && + intent.strength === "weak" && + !intent.nestedScrollable + const isLatestProtected = () => { + const state = scrollController.state() + return state.mode === "following_latest" && state.latestProtected + } scrollDock = createSessionScrollDock({ clearMessageHash: () => clearMessageHash(), clearActiveMessage: () => activeMessage?.clearActiveMessage(), @@ -238,6 +248,13 @@ export function createSessionTimelineInteraction(input: { restoreLatest: event.restoreLatest, }) }, + shouldPreserveLatestForLayoutChange: (event) => { + const state = scrollController.state() + if (state.mode === "following_latest") return true + if (state.latestProtected) return true + if (scrollDock.bottomFollowLocked(lockOwner())) return true + return event.metrics.distanceFromBottom <= latestProtectionBandPx + }, }) const autoScroll = scrollDock.autoScroll const lockOwner = () => input.sessionKey() @@ -300,6 +317,7 @@ export function createSessionTimelineInteraction(input: { } const shouldCancelBottomFollowLockForIntent = (intent: TimelineScrollIntent) => { + if (isLatestProtected() && isWeakUpwardTimelineIntent(intent)) return false if (intent.type === "scrollbar_drag_start" || intent.type === "target_message") return true if (intent.type === "keyboard_scroll") { return intent.key === "ArrowUp" || intent.key === "PageUp" || intent.key === "Home" From ddb52e7d82a0106c165c9391d43cd2c28fa01788 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 10:57:34 +0800 Subject: [PATCH 02/11] test(app): cover timeline latest weak-wheel stability --- .../session/session-scroll-position.spec.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/app/e2e/session/session-scroll-position.spec.ts b/packages/app/e2e/session/session-scroll-position.spec.ts index f57a3753c..0244fc2ec 100644 --- a/packages/app/e2e/session/session-scroll-position.spec.ts +++ b/packages/app/e2e/session/session-scroll-position.spec.ts @@ -46,6 +46,15 @@ type CapturedPageError = { detail?: string } +type CapturedDiagnosticEvent = { + name: string + route_session_id?: string + visible_session_id?: string + timeline_session_id?: string + trace_id?: string + data?: Record +} + function timelineMetrics(page: Page) { return page.evaluate( ({ scrollViewportSelector, turnListSelector }) => { @@ -83,6 +92,18 @@ async function scrollTimelineToBottom(page: Page) { expect(found, "session timeline viewport should exist").toBe(true) } +async function wheelTimelineUpWeakly(page: Page) { + const box = await page.locator(sessionTurnListSelector).evaluate((list, scrollViewportSelector) => { + const viewport = list.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return null + const rect = viewport.getBoundingClientRect() + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + }, scrollViewportSelector) + expect(box, "session timeline viewport should exist").not.toBeNull() + await page.mouse.move(box!.x, box!.y) + await page.mouse.wheel(0, -48) +} + async function installTimelineScrollProbe(page: Page) { await page.evaluate( ({ maxSamples, scrollViewportSelector, turnListSelector }) => { @@ -208,6 +229,35 @@ async function readPageErrorProbe(page: Page) { }) as Promise } +async function installRendererDiagnosticsCapture(page: Page) { + await page.addInitScript(() => { + const win = window as typeof window & { + __pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[] + api?: { + emitRendererDiagnostic?: (event: CapturedDiagnosticEvent) => Promise + } + } + win.__pawwork_renderer_diagnostics = [] + const originalEmit = win.api?.emitRendererDiagnostic?.bind(win.api) + win.api = { + ...(win.api ?? {}), + emitRendererDiagnostic: async (event) => { + win.__pawwork_renderer_diagnostics?.push(JSON.parse(JSON.stringify(event))) + await originalEmit?.(event) + }, + } + }) +} + +async function readRendererDiagnostics(page: Page) { + return page.evaluate(() => { + const win = window as typeof window & { + __pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[] + } + return win.__pawwork_renderer_diagnostics ?? [] + }) as Promise +} + function collectPageErrors(page: Page) { const errors: CapturedPageError[] = [] const describe = (value: unknown) => { @@ -524,6 +574,78 @@ test("does not jump to the top after mod-enter submit from an old message hash", }) }) +test("keeps latest pinned when weak upward wheel lands during answer completion", async ({ + page, + project, + assistant, +}) => { + test.setTimeout(120_000) + + await installRendererDiagnosticsCapture(page) + await project.open() + const sdk = project.sdk + + await withSession(sdk, `e2e latest weak wheel ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await seedSessionTurns({ sdk, sessionID: session.id, count: 14 }) + + await project.gotoSession(session.id) + await expect(page.locator(sessionMessageItemSelector)).toHaveCount(INITIAL_SESSION_WINDOW_MESSAGES, { + timeout: 30_000, + }) + await scrollTimelineToBottom(page) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(20) + + let releaseReply!: () => void + const replyReady = new Promise((resolve) => { + releaseReply = resolve + }) + const token = `latest_weak_wheel_${Date.now()}` + const beforeCalls = await assistant.calls() + await assistant.hold(token, replyReady) + + const tallPrompt = [ + `reply with ${token}`, + ...Array.from({ length: 18 }, (_, line) => `extra context line ${line} ${"content ".repeat(10)}`), + ] + .join(" ") + .trim() + + await sendVisiblePrompt({ page, text: tallPrompt, submitKey: `${modKey}+Enter` }) + await expect.poll(() => assistant.calls(), { timeout: 30_000 }).toBeGreaterThan(beforeCalls) + + const diagnosticCheckpoint = (await readRendererDiagnostics(page)).length + await wheelTimelineUpWeakly(page) + releaseReply() + + await expect(page.locator(sessionMessageItemSelector).last()).toContainText(token, { timeout: 30_000 }) + await expect + .poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom, { timeout: 30_000 }) + .toBeLessThan(60) + + const events = (await readRendererDiagnostics(page)) + .slice(diagnosticCheckpoint) + .filter((event) => event.timeline_session_id === session.id) + expect( + events.some( + (event) => + event.name === "session.timeline.scroll_controller" && + event.data?.reason === "latest_protected_weak_upward_ignored" && + event.data?.ignored_intent_reason === "latest_protected_weak_upward_ignored" && + event.data?.mode_after === "following_latest", + ), + ).toBe(true) + expect( + events.some( + (event) => + event.name === "session.timeline.scroll_controller" && + event.data?.reason === "user_upward_navigation" && + event.data?.mode_after === "reading_history", + ), + ).toBe(false) + }) +}) + test("renders the full initial session window when switching sessions", async ({ page, project }) => { test.setTimeout(120_000) From 7af8fff27c7e96264ad5dffe653e7bd837a2d75a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 11:47:45 +0800 Subject: [PATCH 03/11] test(app): cover timeline anchor rekey fallback --- .../session-timeline-scroll-anchors.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts index 77f21dc0a..a5b9e9a2f 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -371,6 +371,51 @@ describe("session timeline scroll anchors", () => { expect(scroller.scrollTop).toBe(456) }) + test("falls back to the trow anchor when a tool anchor disappears and the message row is re-keyed", () => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(scroller.viewport, "msg_replaced", { top: 180, bottom: 700 }) + appendTimelineAnchor(message, "trow:stable", { top: 210, bottom: 260 }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_placeholder", + offsetFromViewportTop: 0, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:old-key", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackTrowAnchor: { + key: "trow:stable", + offsetFromViewportTop: 88, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_placeholder", + offsetFromViewportTop: 24, + }, + }, + }), + ).toEqual({ + ok: true, + restoredTo: expect.objectContaining({ + kind: "reading", + fallbackTrowAnchor: expect.objectContaining({ key: "trow:stable" }), + }), + }) + expect(scroller.scrollTop).toBe(422) + }) + test("restores nearest target only when it is outside the viewport", () => { const scroller = makeViewport({ scrollTop: 100, From 2dc5297c6ab9d0cf7b23a13a7bf4ec820e978634 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 12:37:44 +0800 Subject: [PATCH 04/11] fix(app): ignore hidden timeline restore anchors --- .../session-timeline-scroll-anchors.test.ts | 55 +++++++++++++++++ .../session-timeline-scroll-anchors.ts | 1 + ...session-timeline-scroll-controller.test.ts | 60 +++++++++++++++++++ .../session-timeline-scroll-controller.ts | 22 +++++-- 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts index a5b9e9a2f..a177d57c8 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -416,6 +416,61 @@ describe("session timeline scroll anchors", () => { expect(scroller.scrollTop).toBe(422) }) + test.each([ + ["hidden", (anchor: HTMLElement) => void (anchor.hidden = true)], + ["zero-size", (anchor: HTMLElement) => stubRect(anchor, { top: 220, bottom: 220 })], + [ + "inside closed details", + (anchor: HTMLElement) => { + const details = document.createElement("details") + anchor.replaceWith(details) + details.appendChild(anchor) + }, + ], + ])("skips a %s primary tool anchor and restores with the fallback trow anchor", (_, hidePrimary) => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(scroller.viewport, "msg_anchor", { top: 180, bottom: 700 }) + const primary = appendTimelineAnchor(message, "tool:hidden-primary", { top: 360, bottom: 420 }) + appendTimelineAnchor(message, "trow:stable", { top: 210, bottom: 260 }) + hidePrimary(primary) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 0, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:hidden-primary", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackTrowAnchor: { + key: "trow:stable", + offsetFromViewportTop: 88, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 24, + }, + }, + }), + ).toEqual({ + ok: true, + restoredTo: expect.objectContaining({ kind: "reading" }), + }) + expect(scroller.scrollTop).toBe(422) + }) + test("restores nearest target only when it is outside the viewport", () => { const scroller = makeViewport({ scrollTop: 100, diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts index b9c5d99c5..4ab564ec9 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -267,6 +267,7 @@ function restoreReading( const anchor = timelineAnchorByKey(viewport, timelineAnchor.key) if (!anchor) continue const anchorRect = anchor.getBoundingClientRect() + if (!isStableVisibleAnchor(anchor, anchorRect, viewportRect)) continue setTimelineScrollTop({ viewport, sink, diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts index e29e802d4..fdf774846 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts @@ -162,6 +162,66 @@ describe("session timeline scroll controller", () => { expect(controller.state().lastSafePosition).toEqual(readingAnchor) }) + test("state snapshots deep-clone nested reading anchors", () => { + const { controller } = makeController() + const nestedReadingAnchor: TimelineSafePosition = { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 24, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:stable", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackTrowAnchor: { + key: "trow:stable", + offsetFromViewportTop: 88, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 24, + }, + } + + controller.intent({ + type: "wheel_scroll", + source: "timeline", + direction: "up", + strength: "strong", + nestedScrollable: false, + }) + controller.observe({ + type: "scroll_sample", + metrics: middleMetrics, + safePosition: nestedReadingAnchor, + }) + controller.observe({ + type: "content_resize", + metrics: middleMetrics, + }) + + const snapshot = controller.state() + if (snapshot.lastSafePosition.kind !== "reading") throw new Error("expected reading safe position") + snapshot.lastSafePosition.primaryAnchor!.key = "tool:mutated" + snapshot.lastSafePosition.fallbackMessage!.messageID = "msg_mutated" + if (snapshot.pendingRecovery.type !== "restore_anchor" || snapshot.pendingRecovery.anchor.kind !== "reading") { + throw new Error("expected reading pending recovery") + } + snapshot.pendingRecovery.anchor.fallbackTrowAnchor!.key = "trow:mutated" + + const next = controller.state() + if (next.lastSafePosition.kind !== "reading") throw new Error("expected reading safe position") + expect(next.lastSafePosition.primaryAnchor?.key).toBe("tool:stable") + expect(next.lastSafePosition.fallbackMessage?.messageID).toBe("msg_anchor") + if (next.pendingRecovery.type !== "restore_anchor" || next.pendingRecovery.anchor.kind !== "reading") { + throw new Error("expected reading pending recovery") + } + expect(next.pendingRecovery.anchor.fallbackTrowAnchor?.key).toBe("trow:stable") + }) + test("scrollbar drag after submit leaves latest protection before scroll samples", () => { const { controller } = makeController() diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts index 3550e006e..2d813d2d7 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -228,14 +228,26 @@ export function classifyTimelineScrollGesture(input: { const noRecovery: TimelineRecovery = { type: "none" } +function cloneSafePosition(position: TimelineSafePosition): TimelineSafePosition { + if (position.kind !== "reading") return { ...position } + return { + ...position, + primaryAnchor: position.primaryAnchor ? { ...position.primaryAnchor } : undefined, + fallbackTrowAnchor: position.fallbackTrowAnchor ? { ...position.fallbackTrowAnchor } : undefined, + fallbackMessage: position.fallbackMessage ? { ...position.fallbackMessage } : undefined, + } +} + +function cloneRecovery(recovery: TimelineRecovery): TimelineRecovery { + if (recovery.type !== "restore_anchor") return { ...recovery } + return { ...recovery, anchor: cloneSafePosition(recovery.anchor) } +} + function cloneState(state: TimelineScrollControllerState): TimelineScrollControllerState { return { ...state, - lastSafePosition: { ...state.lastSafePosition }, - pendingRecovery: - state.pendingRecovery.type === "restore_anchor" - ? { ...state.pendingRecovery, anchor: { ...state.pendingRecovery.anchor } } - : { ...state.pendingRecovery }, + lastSafePosition: cloneSafePosition(state.lastSafePosition), + pendingRecovery: cloneRecovery(state.pendingRecovery), } } From bcd56e93db1f8a28d8aee0ef1f8bd384014a17f2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 13:02:45 +0800 Subject: [PATCH 05/11] fix(app): restore shifted timeline anchors --- .../session-timeline-scroll-anchors.test.ts | 43 +++++++++++++++++++ .../session-timeline-scroll-anchors.ts | 9 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts index a177d57c8..4c4633627 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -471,6 +471,49 @@ describe("session timeline scroll anchors", () => { expect(scroller.scrollTop).toBe(422) }) + test("restores with an offscreen but mounted primary tool anchor after layout shift", () => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(scroller.viewport, "msg_anchor", { top: 180, bottom: 700 }) + appendTimelineAnchor(message, "tool:shifted-primary", { top: 40, bottom: 80 }) + appendTimelineAnchor(message, "trow:stable", { top: 210, bottom: 260 }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 0, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:shifted-primary", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackTrowAnchor: { + key: "trow:stable", + offsetFromViewportTop: 88, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 24, + }, + }, + }), + ).toEqual({ + ok: true, + restoredTo: expect.objectContaining({ kind: "reading" }), + }) + expect(scroller.scrollTop).toBe(268) + }) + test("restores nearest target only when it is outside the viewport", () => { const scroller = makeViewport({ scrollTop: 100, diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts index 4ab564ec9..6fc82dde0 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -64,11 +64,16 @@ function visibleIntersectionPx(rect: DOMRect, viewportRect: DOMRect) { return Math.max(0, Math.min(rect.bottom, viewportRect.bottom) - Math.max(rect.top, viewportRect.top)) } -function isStableVisibleAnchor(el: HTMLElement, rect: DOMRect, viewportRect: DOMRect) { +function isRestorableTimelineAnchor(el: HTMLElement, rect: DOMRect) { if (!el.dataset.timelineAnchor) return false if (isElementHidden(el)) return false if (isInsideClosedDetails(el)) return false if (rect.width <= 0 || rect.height <= 0) return false + return true +} + +function isStableVisibleAnchor(el: HTMLElement, rect: DOMRect, viewportRect: DOMRect) { + if (!isRestorableTimelineAnchor(el, rect)) return false return visibleIntersectionPx(rect, viewportRect) >= MIN_VISIBLE_ANCHOR_INTERSECTION_PX } @@ -267,7 +272,7 @@ function restoreReading( const anchor = timelineAnchorByKey(viewport, timelineAnchor.key) if (!anchor) continue const anchorRect = anchor.getBoundingClientRect() - if (!isStableVisibleAnchor(anchor, anchorRect, viewportRect)) continue + if (!isRestorableTimelineAnchor(anchor, anchorRect)) continue setTimelineScrollTop({ viewport, sink, From 0b763283f808df9e1b1b805485199c7a5f4ffc62 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 14:16:01 +0800 Subject: [PATCH 06/11] fix(app): preserve closed trow summary anchors --- .../session-timeline-scroll-anchors.test.ts | 112 ++++++++++++++++++ .../session-timeline-scroll-anchors.ts | 21 ++-- 2 files changed, 124 insertions(+), 9 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts index 4c4633627..a5f4b9935 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -76,6 +76,29 @@ function appendTimelineAnchor(parent: HTMLElement, key: string, rect: RectInput) return anchor } +function appendClosedTrowDetails( + parent: HTMLElement, + input: { + trowKey: string + trowRect: RectInput + toolKey: string + toolRect: RectInput + }, +) { + const details = document.createElement("details") + const summary = document.createElement("summary") + summary.dataset.timelineAnchor = input.trowKey + stubRect(summary, input.trowRect) + + const body = document.createElement("div") + body.dataset.slot = "trow-body" + const tool = appendTimelineAnchor(body, input.toolKey, input.toolRect) + + details.append(summary, body) + parent.appendChild(details) + return { details, summary, body, tool } +} + describe("session timeline scroll anchors", () => { test("collects near-top and near-bottom metrics from explicit geometry", () => { const { viewport } = makeViewport({ @@ -179,6 +202,48 @@ describe("session timeline scroll anchors", () => { }) }) + test("samples a visible trow summary anchor inside closed details", () => { + const { viewport } = makeViewport({ + scrollTop: 240, + clientHeight: 400, + scrollHeight: 1200, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(viewport, "msg_anchor", { top: 140, bottom: 560 }) + appendClosedTrowDetails(message, { + trowKey: "trow:closed-summary", + trowRect: { top: 180, bottom: 228 }, + toolKey: "tool:closed-body", + toolRect: { top: 260, bottom: 340 }, + }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "reading_history", + renderedStart: 4, + renderedCount: 10, + newestMessageID: "msg_newest", + }), + ).toEqual({ + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 80, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "trow:closed-summary", + offsetFromViewportTop: 80, + scope: "trow", + }, + fallbackTrowAnchor: undefined, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 40, + }, + }) + }) + test("ignores hidden or edge-only timeline anchors while sampling reading position", () => { const { viewport } = makeViewport({ scrollTop: 260, @@ -471,6 +536,53 @@ describe("session timeline scroll anchors", () => { expect(scroller.scrollTop).toBe(422) }) + test("restores with a closed details summary trow fallback after the body tool anchor collapses", () => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(scroller.viewport, "msg_anchor", { top: 180, bottom: 700 }) + appendClosedTrowDetails(message, { + trowKey: "trow:closed-summary", + trowRect: { top: 210, bottom: 260 }, + toolKey: "tool:closed-body", + toolRect: { top: 360, bottom: 420 }, + }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 0, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:closed-body", + offsetFromViewportTop: 72, + scope: "tool", + }, + fallbackTrowAnchor: { + key: "trow:closed-summary", + offsetFromViewportTop: 88, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 24, + }, + }, + }), + ).toEqual({ + ok: true, + restoredTo: expect.objectContaining({ kind: "reading" }), + }) + expect(scroller.scrollTop).toBe(422) + }) + test("restores with an offscreen but mounted primary tool anchor after layout shift", () => { const scroller = makeViewport({ scrollTop: 400, diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts index 6fc82dde0..afdf47d4c 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -51,10 +51,13 @@ function isElementHidden(el: HTMLElement) { return style?.display === "none" || style?.visibility === "hidden" } -function isInsideClosedDetails(el: HTMLElement) { +function isInsideClosedDetailsBody(el: HTMLElement) { let current: HTMLElement | null = el while (current) { - if (current instanceof HTMLDetailsElement && !current.open) return true + const parent = current.parentElement + if (parent instanceof HTMLDetailsElement && !parent.open) { + return current.tagName.toLowerCase() !== "summary" + } current = current.parentElement } return false @@ -64,16 +67,16 @@ function visibleIntersectionPx(rect: DOMRect, viewportRect: DOMRect) { return Math.max(0, Math.min(rect.bottom, viewportRect.bottom) - Math.max(rect.top, viewportRect.top)) } -function isRestorableTimelineAnchor(el: HTMLElement, rect: DOMRect) { +function isTimelineAnchorUsableForRestore(el: HTMLElement, rect: DOMRect) { if (!el.dataset.timelineAnchor) return false if (isElementHidden(el)) return false - if (isInsideClosedDetails(el)) return false + if (isInsideClosedDetailsBody(el)) return false if (rect.width <= 0 || rect.height <= 0) return false return true } -function isStableVisibleAnchor(el: HTMLElement, rect: DOMRect, viewportRect: DOMRect) { - if (!isRestorableTimelineAnchor(el, rect)) return false +function isTimelineAnchorVisibleForSampling(el: HTMLElement, rect: DOMRect, viewportRect: DOMRect) { + if (!isTimelineAnchorUsableForRestore(el, rect)) return false return visibleIntersectionPx(rect, viewportRect) >= MIN_VISIBLE_ANCHOR_INTERSECTION_PX } @@ -108,7 +111,7 @@ function findFallbackTrowAnchor(input: { const key = candidate.dataset.timelineAnchor if (!key || key === input.selectedKey || !key.startsWith("trow:")) continue const rect = candidate.getBoundingClientRect() - if (!isStableVisibleAnchor(candidate, rect, input.viewportRect)) continue + if (!isTimelineAnchorVisibleForSampling(candidate, rect, input.viewportRect)) continue const anchor = makeReadingAnchor(candidate, rect, input.viewportRect) if (anchor) return anchor } @@ -119,7 +122,7 @@ function bestVisibleTimelineAnchor(viewport: HTMLElement) { const readingLine = viewportRect.top + READING_LINE_OFFSET_PX const candidates = timelineAnchorElements(viewport) .map((el) => ({ el, rect: el.getBoundingClientRect() })) - .filter(({ el, rect }) => isStableVisibleAnchor(el, rect, viewportRect)) + .filter(({ el, rect }) => isTimelineAnchorVisibleForSampling(el, rect, viewportRect)) candidates.sort((a, b) => { const aDistance = Math.abs(a.rect.top - readingLine) @@ -272,7 +275,7 @@ function restoreReading( const anchor = timelineAnchorByKey(viewport, timelineAnchor.key) if (!anchor) continue const anchorRect = anchor.getBoundingClientRect() - if (!isRestorableTimelineAnchor(anchor, anchorRect)) continue + if (!isTimelineAnchorUsableForRestore(anchor, anchorRect)) continue setTimelineScrollTop({ viewport, sink, From 460c1f4092a84e10d20e7cc978013b455a8ffb3d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 15:09:22 +0800 Subject: [PATCH 07/11] refactor(app): share weak timeline intent helper --- .../src/pages/session/session-timeline-scroll-controller.ts | 2 +- .../src/pages/session/use-session-timeline-interaction.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts index 2d813d2d7..6972d3aff 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -340,7 +340,7 @@ function isExplicitTopIntent(intent: TimelineScrollIntent) { return false } -function isWeakUpwardTimelineIntent(intent: TimelineScrollIntent) { +export function isWeakUpwardTimelineIntent(intent: TimelineScrollIntent) { return ( (intent.type === "wheel_scroll" || intent.type === "touch_scroll") && intent.direction === "up" && diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts index 3cbaa5272..ef23014a5 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -21,6 +21,7 @@ import { import { shouldApplyTimelineRecoveryForObservation } from "@/pages/session/timeline-layout-recovery-policy" import { createSessionTimelineScrollController, + isWeakUpwardTimelineIntent, type TimelineRecovery, type TimelineScrollControllerResult, type TimelineScrollIntent, @@ -190,11 +191,6 @@ export function createSessionTimelineInteraction(input: { let scrollDock!: ReturnType const latestProtectionBandPx = 120 - const isWeakUpwardTimelineIntent = (intent: TimelineScrollIntent) => - (intent.type === "wheel_scroll" || intent.type === "touch_scroll") && - intent.direction === "up" && - intent.strength === "weak" && - !intent.nestedScrollable const isLatestProtected = () => { const state = scrollController.state() return state.mode === "following_latest" && state.latestProtected From dfb9d596b9dd59ee4a66e98e68799bce2360bcbb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 15:24:45 +0800 Subject: [PATCH 08/11] fix(app): scope trow fallback to selected block --- .../session-timeline-scroll-anchors.test.ts | 72 +++++++++++++++++++ .../session-timeline-scroll-anchors.ts | 15 +++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts index a5f4b9935..3a002997b 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -99,6 +99,26 @@ function appendClosedTrowDetails( return { details, summary, body, tool } } +function appendOpenTrowBlock( + parent: HTMLElement, + input: { + trowKey: string + trowRect: RectInput + toolKey: string + toolRect: RectInput + }, +) { + const block = document.createElement("div") + block.dataset.component = "session-turn-trow-block" + const summary = appendTimelineAnchor(block, input.trowKey, input.trowRect) + const body = document.createElement("div") + body.dataset.slot = "trow-body" + const tool = appendTimelineAnchor(body, input.toolKey, input.toolRect) + block.appendChild(body) + parent.appendChild(block) + return { block, summary, body, tool } +} + describe("session timeline scroll anchors", () => { test("collects near-top and near-bottom metrics from explicit geometry", () => { const { viewport } = makeViewport({ @@ -202,6 +222,58 @@ describe("session timeline scroll anchors", () => { }) }) + test("stores the fallback trow anchor from the selected tool block", () => { + const { viewport } = makeViewport({ + scrollTop: 240, + clientHeight: 400, + scrollHeight: 1200, + rect: { top: 100, bottom: 500 }, + }) + const message = appendMessage(viewport, "msg_anchor", { top: 140, bottom: 760 }) + appendOpenTrowBlock(message, { + trowKey: "trow:first", + trowRect: { top: 150, bottom: 190 }, + toolKey: "tool:first", + toolRect: { top: 250, bottom: 290 }, + }) + appendOpenTrowBlock(message, { + trowKey: "trow:second", + trowRect: { top: 280, bottom: 320 }, + toolKey: "tool:second", + toolRect: { top: 188, bottom: 236 }, + }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "reading_history", + renderedStart: 4, + renderedCount: 10, + newestMessageID: "msg_newest", + }), + ).toEqual({ + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 88, + renderedStart: 4, + renderedCount: 10, + primaryAnchor: { + key: "tool:second", + offsetFromViewportTop: 88, + scope: "tool", + }, + fallbackTrowAnchor: { + key: "trow:second", + offsetFromViewportTop: 180, + scope: "trow", + }, + fallbackMessage: { + messageID: "msg_anchor", + offsetFromViewportTop: 40, + }, + }) + }) + test("samples a visible trow summary anchor inside closed details", () => { const { viewport } = makeViewport({ scrollTop: 240, diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts index afdf47d4c..49aa0d775 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -107,7 +107,20 @@ function findFallbackTrowAnchor(input: { }) { const message = messageElementForAnchor(input.selected) if (!message) return undefined - for (const candidate of timelineAnchorElements(message)) { + const selectedBlock = input.selected.closest('[data-component="session-turn-trow-block"]') + const scopes = selectedBlock instanceof HTMLElement ? [selectedBlock, message] : [message] + for (const scope of scopes) { + const anchor = findFirstVisibleTrowAnchor({ + scope, + selectedKey: input.selectedKey, + viewportRect: input.viewportRect, + }) + if (anchor) return anchor + } +} + +function findFirstVisibleTrowAnchor(input: { scope: HTMLElement; selectedKey: string; viewportRect: DOMRect }) { + for (const candidate of timelineAnchorElements(input.scope)) { const key = candidate.dataset.timelineAnchor if (!key || key === input.selectedKey || !key.startsWith("trow:")) continue const rect = candidate.getBoundingClientRect() From e185f42e769dca9aa067106d286d508bd8b6de8b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 15:55:04 +0800 Subject: [PATCH 09/11] fix(app): suppress ignored latest scroll gestures --- .../session/session-scroll-position.spec.ts | 24 +++++++++++++------ .../src/pages/session/message-timeline.tsx | 14 ++++++++--- ...session-timeline-scroll-controller.test.ts | 10 ++++++-- .../session-timeline-scroll-controller.ts | 2 +- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/session/session-scroll-position.spec.ts b/packages/app/e2e/session/session-scroll-position.spec.ts index 0244fc2ec..e6b3946f1 100644 --- a/packages/app/e2e/session/session-scroll-position.spec.ts +++ b/packages/app/e2e/session/session-scroll-position.spec.ts @@ -101,7 +101,7 @@ async function wheelTimelineUpWeakly(page: Page) { }, scrollViewportSelector) expect(box, "session timeline viewport should exist").not.toBeNull() await page.mouse.move(box!.x, box!.y) - await page.mouse.wheel(0, -48) + await page.mouse.wheel(0, -140) } async function installTimelineScrollProbe(page: Page) { @@ -615,17 +615,27 @@ test("keeps latest pinned when weak upward wheel lands during answer completion" await expect.poll(() => assistant.calls(), { timeout: 30_000 }).toBeGreaterThan(beforeCalls) const diagnosticCheckpoint = (await readRendererDiagnostics(page)).length - await wheelTimelineUpWeakly(page) - releaseReply() + await installTimelineScrollProbe(page) + let samples: TimelineScrollSample[] = [] + const wheelStartedAt = await page.evaluate(() => performance.now()) + try { + await wheelTimelineUpWeakly(page) + releaseReply() - await expect(page.locator(sessionMessageItemSelector).last()).toContainText(token, { timeout: 30_000 }) - await expect - .poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom, { timeout: 30_000 }) - .toBeLessThan(60) + await expect(page.locator(sessionMessageItemSelector).last()).toContainText(token, { timeout: 30_000 }) + await expect + .poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom, { timeout: 30_000 }) + .toBeLessThan(60) + } finally { + samples = await stopTimelineScrollProbe(page) + } const events = (await readRendererDiagnostics(page)) .slice(diagnosticCheckpoint) .filter((event) => event.timeline_session_id === session.id) + const relevantSamples = samples.filter((sample) => sample.at >= wheelStartedAt) + const transientEscapes = relevantSamples.filter((sample) => sample.distanceFromBottom > 20) + expect(transientEscapes).toEqual([]) expect( events.some( (event) => diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5cdb39418..49800879b 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -84,7 +84,7 @@ export function MessageTimeline(props: { onUserScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void - onTimelineScrollIntent: (intent: TimelineScrollIntent) => void + onTimelineScrollIntent: (intent: TimelineScrollIntent) => TimelineScrollControllerResult onTimelineScrollObservation: (observation: TimelineScrollObservation) => TimelineScrollControllerResult centered: boolean setContentRef: (el: HTMLDivElement) => void @@ -383,7 +383,11 @@ export function MessageTimeline(props: { deltaMode: e.deltaMode, }) if (!result) return - props.onTimelineScrollIntent(result.intent) + const intentResult = props.onTimelineScrollIntent(result.intent) + if (intentResult.reason === "latest_protected_weak_upward_ignored") { + if (e.cancelable) e.preventDefault() + return + } if (shouldMarkTimelineBoundaryGesture(result.boundary)) props.onMarkScrollGesture(e.currentTarget) }} onTouchStart={(e) => { @@ -404,7 +408,11 @@ export function MessageTimeline(props: { delta, }) if (!result) return - props.onTimelineScrollIntent(result.intent) + const intentResult = props.onTimelineScrollIntent(result.intent) + if (intentResult.reason === "latest_protected_weak_upward_ignored") { + if (e.cancelable) e.preventDefault() + return + } if (shouldMarkTimelineBoundaryGesture(result.boundary)) props.onMarkScrollGesture(e.currentTarget) }} onTouchEnd={() => { diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts index fdf774846..9540b7ec0 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts @@ -390,7 +390,10 @@ describe("session timeline scroll controller", () => { }) expect(intentResult.reason).toBe("latest_protected_weak_upward_ignored") - expect(intentResult.recovery).toEqual({ type: "none" }) + expect(intentResult.recovery).toEqual({ + type: "restore_latest", + reason: "latest_protected_weak_upward_ignored", + }) expect(scrollResult.accepted).toBe(false) expect(scrollResult.recovery).toEqual({ type: "restore_latest", @@ -417,7 +420,10 @@ describe("session timeline scroll controller", () => { }) expect(result.reason).toBe("latest_protected_weak_upward_ignored") - expect(result.recovery).toEqual({ type: "none" }) + expect(result.recovery).toEqual({ + type: "restore_latest", + reason: "latest_protected_weak_upward_ignored", + }) expect(controller.state().mode).toBe("following_latest") expect(controller.state().latestProtected).toBe(true) }) diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts index 6972d3aff..5cbf7cfbc 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -477,7 +477,7 @@ export function createSessionTimelineScrollController( before, intent, accepted: true, - recovery: noRecovery, + recovery: { type: "restore_latest", reason: "latest_protected_weak_upward_ignored" }, reason: "latest_protected_weak_upward_ignored", }) } From 24ee9ac0e3b7d9890fda13676e969d54fe2ed40c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 15:55:15 +0800 Subject: [PATCH 10/11] fix(desktop): export timeline layout diagnostics --- .../renderer-diagnostics-sanitize.test.ts | 53 +++++++++++++++++++ .../src/main/renderer-diagnostics-sanitize.ts | 17 ++++++ 2 files changed, 70 insertions(+) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts index b29893b8c..a23b84657 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts @@ -180,6 +180,9 @@ describe("renderer diagnostics sanitizer", () => { reason: "submit_restore_latest_after_top_reset", anchor_kind: "latest", anchor_message_id: "msg_latest", + anchor_scope: "latest", + preserve_strategy: "latest", + ignored_intent_reason: "latest_protected_weak_upward_ignored", submit_origin_mode: "following_latest", near_top: true, near_bottom: false, @@ -209,6 +212,9 @@ describe("renderer diagnostics sanitizer", () => { reason: "submit_restore_latest_after_top_reset", anchor_kind: "latest", anchor_message_id: "msg_latest", + anchor_scope: "latest", + preserve_strategy: "latest", + ignored_intent_reason: "latest_protected_weak_upward_ignored", submit_origin_mode: "following_latest", near_top: true, near_bottom: false, @@ -220,4 +226,51 @@ describe("renderer diagnostics sanitizer", () => { }) expect(JSON.stringify(event)).not.toContain("do not keep me") }) + + test("accepts timeline layout transaction diagnostics", () => { + const event = sanitizeRendererDiagnosticEvent( + { + name: "session.timeline.layout_transaction", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + transaction_id: "timeline-layout-1", + transaction_kind: "content-resize", + transaction_phase: "settled", + transaction_status: "before-paint", + mode: "following_latest", + source: "use-session-scroll-dock/contentObserver", + reason: "content-resize", + anchor_kind: "latest", + anchor_message_id: "msg_latest", + fallback_frames: 0, + violation: "anchor_restore_exceeded_fallback_budget", + raw_prompt: "do not keep me", + }, + }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ) + + expect(event).toMatchObject({ + "event.name": "session.timeline.layout_transaction", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + transaction_id: "timeline-layout-1", + transaction_kind: "content-resize", + transaction_phase: "settled", + transaction_status: "before-paint", + mode: "following_latest", + source: "use-session-scroll-dock/contentObserver", + reason: "content-resize", + anchor_kind: "latest", + anchor_message_id: "msg_latest", + fallback_frames: 0, + violation: "anchor_restore_exceeded_fallback_budget", + }, + }) + expect(JSON.stringify(event)).not.toContain("do not keep me") + }) }) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts index c69264f5c..fbebbbc05 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts @@ -50,6 +50,9 @@ const eventDataFields = { "reason", "anchor_kind", "anchor_message_id", + "anchor_scope", + "preserve_strategy", + "ignored_intent_reason", "submit_origin_mode", "near_top", "near_bottom", @@ -58,6 +61,19 @@ const eventDataFields = { "viewport_owner", "coalesced_count", ], + "session.timeline.layout_transaction": [ + "transaction_id", + "transaction_kind", + "transaction_phase", + "transaction_status", + "mode", + "source", + "reason", + "anchor_kind", + "anchor_message_id", + "fallback_frames", + "violation", + ], "session.scroll.sample": [ "scroll_top", "scroll_height", @@ -95,6 +111,7 @@ const eventDataFields = { export const highFrequencyDiagnosticEvents = new Set([ "session.timeline.scroll_controller", + "session.timeline.layout_transaction", "session.scroll.sample", "renderer.perf.sample", ]) From 6bd9fe003bf6e0bfd443a883ba02bc2319a1544c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 26 May 2026 16:15:51 +0800 Subject: [PATCH 11/11] fix(app): preserve explicit timeline scroll intent --- .../session-timeline-scroll-controller.ts | 22 +++++- .../use-session-timeline-interaction.test.ts | 75 +++++++++++++++++++ .../use-session-timeline-interaction.ts | 11 +-- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts index 5cbf7cfbc..c64ee9773 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -210,6 +210,7 @@ export type TimelineGestureClassification = { const STRONG_GESTURE_MIN_PX = 160 const STRONG_GESTURE_VIEWPORT_RATIO = 0.25 +const LATEST_PROTECTION_BAND_PX = 120 export function classifyTimelineScrollGesture(input: { deltaY: number @@ -340,6 +341,25 @@ function isExplicitTopIntent(intent: TimelineScrollIntent) { return false } +export function isExplicitNonLatestTimelineIntent(intent: TimelineScrollIntent | undefined) { + if (!intent) return false + if (intent.type === "scrollbar_drag_start") return true + return isExplicitTopIntent(intent) && !isWeakUpwardTimelineIntent(intent) +} + +export function shouldPreserveLatestForTimelineLayoutChange(input: { + state: TimelineScrollControllerState + bottomFollowLocked: boolean + metrics: Pick +}) { + if (input.state.mode === "following_latest") return true + if (input.state.latestProtected) return true + if (input.bottomFollowLocked) return true + if (isExplicitNonLatestTimelineIntent(input.state.lastIntent)) return false + if (input.state.lastSafePosition.kind !== "latest") return false + return input.metrics.distanceFromBottom <= LATEST_PROTECTION_BAND_PX +} + export function isWeakUpwardTimelineIntent(intent: TimelineScrollIntent) { return ( (intent.type === "wheel_scroll" || intent.type === "touch_scroll") && @@ -570,7 +590,7 @@ export function createSessionTimelineScrollController( state.latestProtected && observation.metrics.nearTop && !observation.metrics.nearBottom && - !(state.lastIntent && isExplicitTopIntent(state.lastIntent) && !isWeakUpwardTimelineIntent(state.lastIntent)) + !isExplicitNonLatestTimelineIntent(state.lastIntent) ) { return result({ before, diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.test.ts b/packages/app/src/pages/session/use-session-timeline-interaction.test.ts index 3085be0ab..871c7e95f 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.test.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.test.ts @@ -1,6 +1,30 @@ import { describe, expect, test } from "bun:test" +import { + createSessionTimelineScrollController, + shouldPreserveLatestForTimelineLayoutChange, + type TimelineScrollMetrics, +} from "./session-timeline-scroll-controller" import { shouldApplyTimelineRecoveryForObservation } from "./timeline-layout-recovery-policy" +const nearLatestMetrics: TimelineScrollMetrics = { + scrollTop: 488, + scrollHeight: 1000, + clientHeight: 400, + distanceFromTop: 488, + distanceFromBottom: 112, + nearTop: false, + nearBottom: false, +} + +function makeControllerStateAfterSubmit() { + const controller = createSessionTimelineScrollController({ + sessionOwner: "ses_1", + viewportOwner: "viewport_1", + }) + controller.intent({ type: "submit", originMode: "following_latest" }) + return controller +} + describe("session timeline interaction layout recovery", () => { test("lets layout transactions own resize recovery while controller still observes resize", () => { expect( @@ -42,3 +66,54 @@ describe("session timeline interaction layout recovery", () => { ).toBe(true) }) }) + +describe("shouldPreserveLatestForTimelineLayoutChange", () => { + test("does not restore latest after ArrowUp leaves latest near the bottom", () => { + const controller = makeControllerStateAfterSubmit() + + controller.intent({ type: "keyboard_scroll", key: "ArrowUp", source: "scroll_view" }) + + expect( + shouldPreserveLatestForTimelineLayoutChange({ + state: controller.state(), + bottomFollowLocked: false, + metrics: nearLatestMetrics, + }), + ).toBe(false) + }) + + test("does not restore latest after strong upward wheel leaves latest near the bottom", () => { + const controller = makeControllerStateAfterSubmit() + + controller.intent({ + type: "wheel_scroll", + source: "timeline", + direction: "up", + strength: "strong", + nestedScrollable: false, + }) + + expect( + shouldPreserveLatestForTimelineLayoutChange({ + state: controller.state(), + bottomFollowLocked: false, + metrics: nearLatestMetrics, + }), + ).toBe(false) + }) + + test("does not restore latest after scrollbar drag leaves latest near the bottom", () => { + const controller = makeControllerStateAfterSubmit() + + controller.intent({ type: "scrollbar_drag_start", source: "scroll_view", metrics: nearLatestMetrics }) + controller.intent({ type: "scrollbar_drag_end", source: "scroll_view", metrics: nearLatestMetrics }) + + expect( + shouldPreserveLatestForTimelineLayoutChange({ + state: controller.state(), + bottomFollowLocked: false, + metrics: nearLatestMetrics, + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts index ef23014a5..3d85b9ddc 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -22,6 +22,7 @@ import { shouldApplyTimelineRecoveryForObservation } from "@/pages/session/timel import { createSessionTimelineScrollController, isWeakUpwardTimelineIntent, + shouldPreserveLatestForTimelineLayoutChange, type TimelineRecovery, type TimelineScrollControllerResult, type TimelineScrollIntent, @@ -190,7 +191,6 @@ export function createSessionTimelineInteraction(input: { }) let scrollDock!: ReturnType - const latestProtectionBandPx = 120 const isLatestProtected = () => { const state = scrollController.state() return state.mode === "following_latest" && state.latestProtected @@ -246,10 +246,11 @@ export function createSessionTimelineInteraction(input: { }, shouldPreserveLatestForLayoutChange: (event) => { const state = scrollController.state() - if (state.mode === "following_latest") return true - if (state.latestProtected) return true - if (scrollDock.bottomFollowLocked(lockOwner())) return true - return event.metrics.distanceFromBottom <= latestProtectionBandPx + return shouldPreserveLatestForTimelineLayoutChange({ + state, + bottomFollowLocked: scrollDock.bottomFollowLocked(lockOwner()), + metrics: event.metrics, + }) }, }) const autoScroll = scrollDock.autoScroll