From a13817875420dff76d9c3ba3d0943b300c36d58e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 20:48:01 +0800 Subject: [PATCH 01/11] feat: add session timeline scroll controller --- ...session-timeline-scroll-controller.test.ts | 306 ++++++++++ .../session-timeline-scroll-controller.ts | 545 ++++++++++++++++++ 2 files changed, 851 insertions(+) create mode 100644 packages/app/src/pages/session/session-timeline-scroll-controller.test.ts create mode 100644 packages/app/src/pages/session/session-timeline-scroll-controller.ts 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 new file mode 100644 index 000000000..e29f41827 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, test } from "bun:test" +import { + createSessionTimelineScrollController, + createTimelineScrollControllerDiagnostic, + type TimelineScrollDiagnosticEvent, + type TimelineScrollMetrics, + type TimelineSafePosition, +} from "./session-timeline-scroll-controller" + +const bottomMetrics: TimelineScrollMetrics = { + scrollTop: 853, + scrollHeight: 1674, + clientHeight: 821, + distanceFromTop: 853, + distanceFromBottom: 0, + nearTop: false, + nearBottom: true, +} + +const topMetrics: TimelineScrollMetrics = { + scrollTop: 6, + scrollHeight: 1674, + clientHeight: 821, + distanceFromTop: 6, + distanceFromBottom: 847, + nearTop: true, + nearBottom: false, +} + +const middleMetrics: TimelineScrollMetrics = { + scrollTop: 420, + scrollHeight: 1674, + clientHeight: 821, + distanceFromTop: 420, + distanceFromBottom: 433, + nearTop: false, + nearBottom: false, +} + +const readingAnchor: TimelineSafePosition = { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 24, + renderedStart: 4, + renderedCount: 10, +} + +function makeController() { + const diagnostics: TimelineScrollDiagnosticEvent[] = [] + const controller = createSessionTimelineScrollController({ + sessionOwner: "ses_1", + viewportOwner: "viewport_1", + routeSessionID: "ses_1", + visibleSessionID: "ses_1", + timelineSessionID: "ses_1", + emitDiagnostic: (event) => diagnostics.push(event), + }) + return { controller, diagnostics } +} + +describe("session timeline scroll controller", () => { + test("restores latest when submit is followed by an observation-only top reset", () => { + const { controller, diagnostics } = makeController() + + controller.observe({ + type: "scroll_sample", + metrics: bottomMetrics, + safePosition: { kind: "latest", messageID: "msg_latest" }, + }) + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + + const result = controller.observe({ + type: "scroll_sample", + metrics: topMetrics, + }) + + expect(result).toEqual({ + accepted: false, + recovery: { + type: "restore_latest", + reason: "submit_restore_latest_after_top_reset", + }, + reason: "submit_restore_latest_after_top_reset", + }) + expect(controller.state().mode).toBe("following_latest") + expect(diagnostics.at(-1)).toMatchObject({ + name: "session.timeline.scroll_controller", + data: { + accepted: false, + recovery: true, + reason: "submit_restore_latest_after_top_reset", + mode_before: "following_latest", + mode_after: "following_latest", + observation_type: "scroll_sample", + near_top: true, + near_bottom: false, + submit_origin_mode: "following_latest", + }, + }) + }) + + test("accepts explicit Home navigation to history instead of restoring latest", () => { + const { controller } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + const intentResult = controller.intent({ + type: "keyboard_scroll", + key: "Home", + source: "scroll_view", + }) + const scrollResult = controller.observe({ + type: "scroll_sample", + metrics: topMetrics, + safePosition: readingAnchor, + }) + + expect(intentResult.reason).toBe("explicit_top_navigation") + expect(scrollResult.accepted).toBe(true) + expect(scrollResult.recovery).toEqual({ type: "none" }) + expect(controller.state().mode).toBe("reading_history") + expect(controller.state().lastSafePosition).toEqual(readingAnchor) + }) + + test("strong upward wheel intent can leave latest", () => { + const { controller } = makeController() + + const result = controller.intent({ + type: "wheel_scroll", + source: "timeline", + direction: "up", + strength: "strong", + nestedScrollable: false, + }) + + expect(result.reason).toBe("strong_upward_navigation") + expect(controller.state().mode).toBe("reading_history") + }) + + test("weak upward wheel intent does not leave latest", () => { + const { controller } = makeController() + + const result = controller.intent({ + type: "wheel_scroll", + source: "timeline", + direction: "up", + strength: "weak", + nestedScrollable: false, + }) + + expect(result.reason).toBe("weak_scroll_observed") + expect(controller.state().mode).toBe("following_latest") + }) + + test("explicit bottom navigation rejoins latest from reading", () => { + const { controller } = makeController() + + controller.intent({ + type: "keyboard_scroll", + key: "PageUp", + source: "scroll_view", + }) + controller.observe({ + type: "scroll_sample", + metrics: middleMetrics, + safePosition: readingAnchor, + }) + + const result = controller.intent({ + type: "scrollbar_drag_end", + source: "scroll_view", + metrics: bottomMetrics, + }) + + expect(result.reason).toBe("explicit_bottom_navigation") + expect(controller.state().mode).toBe("following_latest") + expect(controller.state().lastSafePosition).toEqual({ kind: "latest" }) + }) + + test("reading anchor is restored for after-layout resize observations", () => { + const { controller } = makeController() + + controller.intent({ + type: "keyboard_scroll", + key: "PageUp", + source: "scroll_view", + }) + controller.observe({ + type: "scroll_sample", + metrics: middleMetrics, + safePosition: readingAnchor, + }) + + const result = controller.observe({ + type: "content_resize", + metrics: middleMetrics, + }) + + expect(result).toEqual({ + accepted: true, + recovery: { + type: "restore_anchor", + reason: "content_resize_preserve_reading", + anchor: readingAnchor, + }, + reason: "content_resize_preserve_reading", + }) + expect(controller.state().mode).toBe("reading_history") + }) + + test("target message remains the recovery anchor across window changes", () => { + const { controller } = makeController() + + controller.intent({ + type: "target_message", + messageID: "msg_target", + align: "nearest", + }) + + const result = controller.observe({ + type: "window_changed", + renderedStart: 0, + renderedCount: 20, + metrics: middleMetrics, + }) + + expect(result).toEqual({ + accepted: true, + recovery: { + type: "restore_anchor", + reason: "window_changed_preserve_target", + anchor: { + kind: "target_message", + messageID: "msg_target", + align: "nearest", + loadPolicy: "load_until_visible", + }, + }, + reason: "window_changed_preserve_target", + }) + expect(controller.state().mode).toBe("targeting_message") + }) + + test("owner detach cancels pending recovery", () => { + const { controller, diagnostics } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + + const result = controller.detach({ + sessionOwner: "old_ses", + viewportOwner: "old_viewport", + }) + + expect(result.accepted).toBe(false) + expect(result.reason).toBe("owner_mismatch_cancelled") + expect(controller.state().pendingRecovery).toEqual({ type: "none" }) + expect(diagnostics.at(-1)).toMatchObject({ + data: { + accepted: false, + reason: "owner_mismatch_cancelled", + }, + }) + }) + + test("diagnostic builder uses the existing renderer diagnostic shape", () => { + const event = createTimelineScrollControllerDiagnostic({ + routeSessionID: "route", + visibleSessionID: "visible", + timelineSessionID: "timeline", + data: { + mode_before: "following_latest", + mode_after: "following_latest", + accepted: false, + recovery: true, + reason: "submit_restore_latest_after_top_reset", + session_owner: "ses_1", + viewport_owner: "viewport_1", + }, + }) + + expect(event).toEqual({ + name: "session.timeline.scroll_controller", + route_session_id: "route", + visible_session_id: "visible", + timeline_session_id: "timeline", + data: { + mode_before: "following_latest", + mode_after: "following_latest", + accepted: false, + recovery: true, + reason: "submit_restore_latest_after_top_reset", + session_owner: "ses_1", + viewport_owner: "viewport_1", + }, + }) + }) +}) diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts new file mode 100644 index 000000000..585f0417a --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -0,0 +1,545 @@ +import type { RendererDiagnosticInput } from "@/context/platform" + +export type TimelineScrollMode = "following_latest" | "reading_history" | "targeting_message" + +export type TimelineDockKind = "composer" | "question" | "permission" | "todo" | "followup" | "revert" | "prompt" + +export type TimelineScrollMetrics = { + scrollTop: number + scrollHeight: number + clientHeight: number + distanceFromTop: number + distanceFromBottom: number + nearTop: boolean + nearBottom: boolean +} + +export type TimelineSafePosition = + | { kind: "latest"; messageID?: string } + | { + kind: "reading" + anchorMessageID: string + offsetFromViewportTop: number + renderedStart: number + renderedCount: number + } + | { + kind: "target_message" + messageID: string + align: "nearest" | "top" | "center" + offsetFromViewportTop?: number + loadPolicy: "load_until_visible" | "visible_only" + } + +export type TimelineScrollReason = + | "submit_follow_latest" + | "submit_restore_latest_after_top_reset" + | "explicit_top_navigation" + | "explicit_bottom_navigation" + | "strong_upward_navigation" + | "strong_downward_navigation" + | "weak_scroll_observed" + | "scrollbar_drag_started" + | "scrollbar_drag_preserve_reading" + | "reading_anchor_preserved" + | "content_resize_preserve_reading" + | "dock_resize_preserve_anchor" + | "window_changed_preserve_target" + | "target_message_requested" + | "target_load_exhausted_fallback" + | "owner_mismatch_cancelled" + | "anchor_unrecoverable_fallback" + +export type TimelineRecovery = + | { type: "none" } + | { + type: "restore_anchor" + reason: TimelineScrollReason + anchor: TimelineSafePosition + } + | { + type: "restore_latest" + reason: TimelineScrollReason + } + +export type TimelineScrollIntent = + | { + type: "keyboard_scroll" + key: "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown" | "Home" | "End" + source: "scroll_view" + } + | { + type: "scrollbar_drag_start" | "scrollbar_drag_end" + source: "scroll_view" + metrics: TimelineScrollMetrics + } + | { + type: "wheel_scroll" | "touch_scroll" + source: "timeline" + direction: "up" | "down" + strength: "weak" | "strong" + nestedScrollable: boolean + } + | { + type: "jump_latest" + source: "button" | "keyboard" | "submit" + } + | { + type: "submit" + originMode: TimelineScrollMode + } + | { + type: "target_message" + messageID: string + align: "nearest" | "top" | "center" + } + +export type TimelineScrollObservation = + | { + type: "scroll_sample" + metrics: TimelineScrollMetrics + safePosition?: TimelineSafePosition + } + | { + type: "window_changed" + renderedStart: number + renderedCount: number + metrics: TimelineScrollMetrics + } + | { + type: "content_resize" + metrics: TimelineScrollMetrics + } + | { + type: "dock_resize" + dockKind: TimelineDockKind + previousDockHeight: number + nextDockHeight: number + metrics: TimelineScrollMetrics + } + | { + type: "owner_detached" + sessionOwner: string + viewportOwner: string + } + +export type TimelineScrollControllerResult = { + accepted: boolean + recovery: TimelineRecovery + reason: TimelineScrollReason +} + +export type TimelineScrollControllerState = { + mode: TimelineScrollMode + lastSafePosition: TimelineSafePosition + lastIntent?: TimelineScrollIntent + pendingRecovery: TimelineRecovery + sessionOwner: string + viewportOwner: string + submitOriginMode?: TimelineScrollMode + latestProtected: boolean +} + +export type TimelineScrollDiagnosticData = { + mode_before: TimelineScrollMode + mode_after: TimelineScrollMode + intent_type?: string + intent_source?: string + observation_type?: string + accepted: boolean + recovery: boolean + reason: TimelineScrollReason + anchor_kind?: TimelineSafePosition["kind"] + anchor_message_id?: string + submit_origin_mode?: TimelineScrollMode + near_top?: boolean + near_bottom?: boolean + near_anchor?: boolean + session_owner: string + viewport_owner: string + coalesced_count?: number +} + +export type TimelineScrollDiagnosticEvent = RendererDiagnosticInput & { + name: "session.timeline.scroll_controller" + data: TimelineScrollDiagnosticData +} + +export type SessionTimelineScrollControllerOptions = { + sessionOwner: string + viewportOwner: string + routeSessionID?: string + visibleSessionID?: string + timelineSessionID?: string + emitDiagnostic?: (event: TimelineScrollDiagnosticEvent) => void +} + +export type SessionTimelineScrollController = { + state: () => TimelineScrollControllerState + intent: (intent: TimelineScrollIntent) => TimelineScrollControllerResult + observe: (observation: TimelineScrollObservation) => TimelineScrollControllerResult + detach: (owner: { sessionOwner: string; viewportOwner: string }) => TimelineScrollControllerResult +} + +const noRecovery: TimelineRecovery = { type: "none" } + +function cloneState(state: TimelineScrollControllerState): TimelineScrollControllerState { + return { + ...state, + lastSafePosition: { ...state.lastSafePosition }, + pendingRecovery: + state.pendingRecovery.type === "restore_anchor" + ? { ...state.pendingRecovery, anchor: { ...state.pendingRecovery.anchor } } + : { ...state.pendingRecovery }, + } +} + +function anchorKind(position: TimelineSafePosition | undefined) { + return position?.kind +} + +function anchorMessageID(position: TimelineSafePosition | undefined) { + if (!position) return undefined + if (position.kind === "latest") return position.messageID + if (position.kind === "reading") return position.anchorMessageID + return position.messageID +} + +function diagnosticData(input: { + before: TimelineScrollControllerState + after: TimelineScrollControllerState + intent?: TimelineScrollIntent + observation?: TimelineScrollObservation + accepted: boolean + recovery: TimelineRecovery + reason: TimelineScrollReason + coalescedCount?: number +}): TimelineScrollDiagnosticData { + const observation = input.observation + const metrics = observation && "metrics" in observation ? observation.metrics : undefined + const intent = input.intent + const intentSource = intent && "source" in intent ? intent.source : undefined + const anchor = input.recovery.type === "restore_anchor" ? input.recovery.anchor : input.after.lastSafePosition + return { + mode_before: input.before.mode, + mode_after: input.after.mode, + intent_type: input.intent?.type, + intent_source: intentSource, + observation_type: input.observation?.type, + accepted: input.accepted, + recovery: input.recovery.type !== "none", + reason: input.reason, + anchor_kind: anchorKind(anchor), + anchor_message_id: anchorMessageID(anchor), + submit_origin_mode: input.after.submitOriginMode, + near_top: metrics?.nearTop, + near_bottom: metrics?.nearBottom, + near_anchor: input.recovery.type === "restore_anchor", + session_owner: input.after.sessionOwner, + viewport_owner: input.after.viewportOwner, + coalesced_count: input.coalescedCount, + } +} + +export function createTimelineScrollControllerDiagnostic(input: { + routeSessionID?: string + visibleSessionID?: string + timelineSessionID?: string + data: TimelineScrollDiagnosticData +}): TimelineScrollDiagnosticEvent { + return { + name: "session.timeline.scroll_controller", + route_session_id: input.routeSessionID, + visible_session_id: input.visibleSessionID, + timeline_session_id: input.timelineSessionID, + data: input.data, + } +} + +function isExplicitTopIntent(intent: TimelineScrollIntent) { + if (intent.type === "keyboard_scroll") return intent.key === "Home" || intent.key === "PageUp" + if (intent.type === "wheel_scroll" || intent.type === "touch_scroll") { + return intent.direction === "up" && intent.strength === "strong" && !intent.nestedScrollable + } + if (intent.type === "scrollbar_drag_end") return !intent.metrics.nearBottom + return false +} + +function isExplicitBottomIntent(intent: TimelineScrollIntent) { + if (intent.type === "keyboard_scroll") return intent.key === "End" + if (intent.type === "jump_latest" || intent.type === "submit") return true + if (intent.type === "scrollbar_drag_end") return intent.metrics.nearBottom + return false +} + +function updateSafePosition(state: TimelineScrollControllerState, safePosition: TimelineSafePosition | undefined) { + if (safePosition) state.lastSafePosition = safePosition +} + +export function createSessionTimelineScrollController( + options: SessionTimelineScrollControllerOptions, +): SessionTimelineScrollController { + const state: TimelineScrollControllerState = { + mode: "following_latest", + lastSafePosition: { kind: "latest" }, + pendingRecovery: noRecovery, + sessionOwner: options.sessionOwner, + viewportOwner: options.viewportOwner, + latestProtected: false, + } + + const emit = (input: { + before: TimelineScrollControllerState + intent?: TimelineScrollIntent + observation?: TimelineScrollObservation + accepted: boolean + recovery: TimelineRecovery + reason: TimelineScrollReason + }) => { + options.emitDiagnostic?.( + createTimelineScrollControllerDiagnostic({ + routeSessionID: options.routeSessionID, + visibleSessionID: options.visibleSessionID, + timelineSessionID: options.timelineSessionID, + data: diagnosticData({ ...input, after: cloneState(state) }), + }), + ) + } + + const result = (input: { + before: TimelineScrollControllerState + intent?: TimelineScrollIntent + observation?: TimelineScrollObservation + accepted: boolean + recovery: TimelineRecovery + reason: TimelineScrollReason + }): TimelineScrollControllerResult => { + state.pendingRecovery = input.recovery + emit(input) + return { + accepted: input.accepted, + recovery: input.recovery, + reason: input.reason, + } + } + + return { + state: () => cloneState(state), + intent(intent) { + const before = cloneState(state) + state.lastIntent = intent + + if (intent.type === "submit") { + state.mode = "following_latest" + state.submitOriginMode = intent.originMode + state.latestProtected = true + state.lastSafePosition = { kind: "latest" } + return result({ + before, + intent, + accepted: true, + recovery: { type: "restore_latest", reason: "submit_follow_latest" }, + reason: "submit_follow_latest", + }) + } + + if (intent.type === "target_message") { + state.mode = "targeting_message" + state.latestProtected = false + state.lastSafePosition = { + kind: "target_message", + messageID: intent.messageID, + align: intent.align, + loadPolicy: "load_until_visible", + } + return result({ + before, + intent, + accepted: true, + recovery: { type: "restore_anchor", reason: "target_message_requested", anchor: state.lastSafePosition }, + reason: "target_message_requested", + }) + } + + if (isExplicitBottomIntent(intent)) { + state.mode = "following_latest" + state.latestProtected = true + state.lastSafePosition = { kind: "latest" } + return result({ + before, + intent, + accepted: true, + recovery: { type: "restore_latest", reason: "explicit_bottom_navigation" }, + reason: "explicit_bottom_navigation", + }) + } + + if (isExplicitTopIntent(intent)) { + state.mode = "reading_history" + state.latestProtected = false + const reason = intent.type === "keyboard_scroll" ? "explicit_top_navigation" : "strong_upward_navigation" + return result({ + before, + intent, + accepted: true, + recovery: noRecovery, + reason, + }) + } + + if ( + (intent.type === "wheel_scroll" || intent.type === "touch_scroll") && + intent.direction === "down" && + intent.strength === "strong" && + !intent.nestedScrollable + ) { + return result({ + before, + intent, + accepted: true, + recovery: noRecovery, + reason: "strong_downward_navigation", + }) + } + + if (intent.type === "scrollbar_drag_start") { + return result({ + before, + intent, + accepted: true, + recovery: noRecovery, + reason: "scrollbar_drag_started", + }) + } + + return result({ + before, + intent, + accepted: true, + recovery: noRecovery, + reason: "weak_scroll_observed", + }) + }, + observe(observation) { + const before = cloneState(state) + + if (observation.type === "owner_detached") { + state.pendingRecovery = noRecovery + state.latestProtected = false + return result({ + before, + observation, + accepted: true, + recovery: noRecovery, + reason: "owner_mismatch_cancelled", + }) + } + + if (observation.type === "scroll_sample") { + if (observation.metrics.nearBottom) { + updateSafePosition(state, observation.safePosition ?? { kind: "latest" }) + if (state.lastIntent && isExplicitBottomIntent(state.lastIntent)) { + state.mode = "following_latest" + state.latestProtected = true + return result({ + before, + observation, + accepted: true, + recovery: noRecovery, + reason: "explicit_bottom_navigation", + }) + } + } + + if ( + state.mode === "following_latest" && + state.latestProtected && + observation.metrics.nearTop && + !observation.metrics.nearBottom && + !(state.lastIntent && isExplicitTopIntent(state.lastIntent)) + ) { + return result({ + before, + observation, + accepted: false, + recovery: { type: "restore_latest", reason: "submit_restore_latest_after_top_reset" }, + reason: "submit_restore_latest_after_top_reset", + }) + } + + updateSafePosition(state, observation.safePosition) + return result({ + before, + observation, + accepted: true, + recovery: noRecovery, + reason: state.mode === "reading_history" ? "reading_anchor_preserved" : "weak_scroll_observed", + }) + } + + if (state.mode === "reading_history" && state.lastSafePosition.kind === "reading") { + const reason = + observation.type === "content_resize" ? "content_resize_preserve_reading" : "reading_anchor_preserved" + return result({ + before, + observation, + accepted: true, + recovery: { + type: "restore_anchor", + reason, + anchor: state.lastSafePosition, + }, + reason, + }) + } + + if (state.mode === "targeting_message" && state.lastSafePosition.kind === "target_message") { + return result({ + before, + observation, + accepted: true, + recovery: { + type: "restore_anchor", + reason: "window_changed_preserve_target", + anchor: state.lastSafePosition, + }, + reason: "window_changed_preserve_target", + }) + } + + if (state.mode === "following_latest") { + return result({ + before, + observation, + accepted: true, + recovery: { type: "restore_latest", reason: "explicit_bottom_navigation" }, + reason: "explicit_bottom_navigation", + }) + } + + return result({ + before, + observation, + accepted: true, + recovery: noRecovery, + reason: "weak_scroll_observed", + }) + }, + detach(owner) { + const before = cloneState(state) + state.pendingRecovery = noRecovery + state.latestProtected = false + return result({ + before, + observation: { + type: "owner_detached", + sessionOwner: owner.sessionOwner, + viewportOwner: owner.viewportOwner, + }, + accepted: owner.sessionOwner === state.sessionOwner && owner.viewportOwner === state.viewportOwner, + recovery: noRecovery, + reason: "owner_mismatch_cancelled", + }) + }, + } +} From e2bc36127d33a5033cead6229a32a4fd6f48c128 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 20:55:36 +0800 Subject: [PATCH 02/11] feat: add timeline scroll anchor helpers --- .../session-timeline-scroll-anchors.test.ts | 247 ++++++++++++++++++ .../session-timeline-scroll-anchors.ts | 149 +++++++++++ 2 files changed, 396 insertions(+) create mode 100644 packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts create mode 100644 packages/app/src/pages/session/session-timeline-scroll-anchors.ts 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 new file mode 100644 index 000000000..72a1d96c4 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from "bun:test" +import { + collectTimelineScrollMetrics, + restoreTimelineSafePosition, + sampleTimelineSafePosition, +} from "./session-timeline-scroll-anchors" + +type RectInput = { + top: number + bottom: number +} + +function stubRect(el: HTMLElement, rect: RectInput) { + Object.defineProperty(el, "getBoundingClientRect", { + configurable: true, + value: () => ({ + x: 0, + y: rect.top, + width: 720, + height: rect.bottom - rect.top, + top: rect.top, + right: 720, + bottom: rect.bottom, + left: 0, + toJSON: () => ({}), + }), + }) +} + +function makeViewport(input: { scrollTop: number; clientHeight: number; scrollHeight: number; rect?: RectInput }) { + const viewport = document.createElement("div") + let top = input.scrollTop + let height = input.scrollHeight + + Object.defineProperties(viewport, { + clientHeight: { value: input.clientHeight, configurable: true }, + scrollHeight: { + configurable: true, + get: () => height, + set: (value) => { + height = value + }, + }, + scrollTop: { + configurable: true, + get: () => top, + set: (value) => { + top = value + }, + }, + }) + stubRect(viewport, input.rect ?? { top: 100, bottom: 100 + input.clientHeight }) + + return { + viewport, + get scrollTop() { + return top + }, + } +} + +function appendMessage(viewport: HTMLElement, id: string, rect: RectInput) { + const message = document.createElement("div") + message.dataset.messageId = id + stubRect(message, rect) + viewport.appendChild(message) + return message +} + +describe("session timeline scroll anchors", () => { + test("collects near-top and near-bottom metrics from explicit geometry", () => { + const { viewport } = makeViewport({ + scrollTop: 398, + clientHeight: 400, + scrollHeight: 800, + }) + + expect(collectTimelineScrollMetrics(viewport)).toEqual({ + scrollTop: 398, + scrollHeight: 800, + clientHeight: 400, + distanceFromTop: 398, + distanceFromBottom: 2, + nearTop: false, + nearBottom: true, + }) + }) + + test("samples latest with newest message id in following mode", () => { + const { viewport } = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 800, + }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "following_latest", + renderedStart: 20, + renderedCount: 10, + newestMessageID: "msg_29", + }), + ).toEqual({ kind: "latest", messageID: "msg_29" }) + }) + + test("samples first visible message as a reading anchor", () => { + const { viewport } = makeViewport({ + scrollTop: 260, + clientHeight: 400, + scrollHeight: 1200, + rect: { top: 100, bottom: 500 }, + }) + appendMessage(viewport, "msg_above", { top: -100, bottom: 50 }) + appendMessage(viewport, "msg_anchor", { top: 124, bottom: 240 }) + appendMessage(viewport, "msg_next", { top: 260, bottom: 420 }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "reading_history", + renderedStart: 4, + renderedCount: 10, + newestMessageID: "msg_newest", + }), + ).toEqual({ + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 24, + renderedStart: 4, + renderedCount: 10, + }) + }) + + test("restores latest to bottom without sentinel", () => { + const scroller = makeViewport({ + scrollTop: 120, + clientHeight: 400, + scrollHeight: 1000, + }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { kind: "latest", messageID: "msg_latest" }, + }), + ).toEqual({ ok: true, restoredTo: { kind: "latest", messageID: "msg_latest" } }) + expect(scroller.scrollTop).toBe(600) + }) + + test("restores reading anchor to its previous viewport offset", () => { + const scroller = makeViewport({ + scrollTop: 400, + clientHeight: 400, + scrollHeight: 1400, + rect: { top: 100, bottom: 500 }, + }) + appendMessage(scroller.viewport, "msg_anchor", { top: 180, bottom: 300 }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 24, + renderedStart: 4, + renderedCount: 10, + }, + }), + ).toEqual({ + ok: true, + restoredTo: { + kind: "reading", + anchorMessageID: "msg_anchor", + offsetFromViewportTop: 24, + renderedStart: 4, + renderedCount: 10, + }, + }) + expect(scroller.scrollTop).toBe(456) + }) + + test("restores nearest target only when it is outside the viewport", () => { + const scroller = makeViewport({ + scrollTop: 100, + clientHeight: 400, + scrollHeight: 1200, + rect: { top: 100, bottom: 500 }, + }) + appendMessage(scroller.viewport, "msg_visible", { top: 160, bottom: 260 }) + appendMessage(scroller.viewport, "msg_below", { top: 540, bottom: 700 }) + + expect( + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "target_message", + messageID: "msg_visible", + align: "nearest", + loadPolicy: "load_until_visible", + }, + }), + ).toEqual({ + ok: true, + restoredTo: { + kind: "target_message", + messageID: "msg_visible", + align: "nearest", + loadPolicy: "load_until_visible", + }, + }) + expect(scroller.scrollTop).toBe(100) + + restoreTimelineSafePosition({ + viewport: scroller.viewport, + position: { + kind: "target_message", + messageID: "msg_below", + align: "nearest", + loadPolicy: "load_until_visible", + }, + }) + expect(scroller.scrollTop).toBe(300) + }) + + test("returns a typed failure when an anchor is not mounted", () => { + const { viewport } = makeViewport({ + scrollTop: 100, + clientHeight: 400, + scrollHeight: 1200, + }) + + expect( + restoreTimelineSafePosition({ + viewport, + position: { + kind: "reading", + anchorMessageID: "msg_missing", + offsetFromViewportTop: 0, + renderedStart: 0, + renderedCount: 10, + }, + }), + ).toEqual({ ok: false, reason: "anchor_not_mounted" }) + }) +}) diff --git a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts new file mode 100644 index 000000000..065a42fb3 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -0,0 +1,149 @@ +import type { TimelineSafePosition, TimelineScrollMetrics, TimelineScrollMode } from "./session-timeline-scroll-controller" + +export type TimelineAnchorRestoreResult = + | { ok: true; restoredTo: TimelineSafePosition } + | { ok: false; reason: "anchor_not_mounted" | "viewport_missing" | "invalid_anchor" } + +function messageElements(viewport: HTMLElement) { + return Array.from(viewport.querySelectorAll("[data-message-id]")).filter( + (el): el is HTMLElement => el instanceof HTMLElement, + ) +} + +function messageElementByID(viewport: HTMLElement, messageID: string) { + return messageElements(viewport).find((el) => el.dataset.messageId === messageID) +} + +function firstVisibleMessage(viewport: HTMLElement) { + const viewportRect = viewport.getBoundingClientRect() + return messageElements(viewport) + .map((el) => ({ el, rect: el.getBoundingClientRect() })) + .filter((item) => item.rect.bottom > viewportRect.top && item.rect.top < viewportRect.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0] +} + +export function collectTimelineScrollMetrics(viewport: HTMLElement): TimelineScrollMetrics { + const max = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + const distanceFromBottom = Math.max(0, max - viewport.scrollTop) + return { + scrollTop: viewport.scrollTop, + scrollHeight: viewport.scrollHeight, + clientHeight: viewport.clientHeight, + distanceFromTop: viewport.scrollTop, + distanceFromBottom, + nearTop: viewport.scrollTop <= 12, + nearBottom: distanceFromBottom <= 2, + } +} + +export function sampleTimelineSafePosition(args: { + viewport: HTMLElement + mode: TimelineScrollMode + renderedStart: number + renderedCount: number + newestMessageID?: string + targetMessageID?: string +}): TimelineSafePosition { + if (args.mode === "following_latest") return { kind: "latest", messageID: args.newestMessageID } + if (args.mode === "targeting_message" && args.targetMessageID) { + return { + kind: "target_message", + messageID: args.targetMessageID, + align: "nearest", + loadPolicy: "load_until_visible", + } + } + + const visible = firstVisibleMessage(args.viewport) + const messageID = visible?.el.dataset.messageId + if (!visible || !messageID) return { kind: "latest", messageID: args.newestMessageID } + + const viewportRect = args.viewport.getBoundingClientRect() + return { + kind: "reading", + anchorMessageID: messageID, + offsetFromViewportTop: visible.rect.top - viewportRect.top, + renderedStart: args.renderedStart, + renderedCount: args.renderedCount, + } +} + +function restoreLatest(viewport: HTMLElement, bottomSentinel?: HTMLElement | null) { + if (bottomSentinel) { + const viewportRect = viewport.getBoundingClientRect() + const sentinelRect = bottomSentinel.getBoundingClientRect() + viewport.scrollTop = Math.max(0, viewport.scrollTop + sentinelRect.bottom - viewportRect.bottom) + return + } + viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight) +} + +function restoreReading(viewport: HTMLElement, position: Extract) { + const anchor = messageElementByID(viewport, position.anchorMessageID) + if (!anchor) return false + const viewportRect = viewport.getBoundingClientRect() + const anchorRect = anchor.getBoundingClientRect() + viewport.scrollTop = Math.max(0, viewport.scrollTop + anchorRect.top - viewportRect.top - position.offsetFromViewportTop) + return true +} + +function restoreTarget(viewport: HTMLElement, position: Extract) { + const target = messageElementByID(viewport, position.messageID) + if (!target) return false + + const viewportRect = viewport.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const offset = position.offsetFromViewportTop + + if (typeof offset === "number") { + viewport.scrollTop = Math.max(0, viewport.scrollTop + targetRect.top - viewportRect.top - offset) + return true + } + + if (position.align === "nearest") { + if (targetRect.top >= viewportRect.top && targetRect.bottom <= viewportRect.bottom) return true + if (targetRect.top < viewportRect.top) { + viewport.scrollTop = Math.max(0, viewport.scrollTop + targetRect.top - viewportRect.top) + return true + } + viewport.scrollTop = Math.max(0, viewport.scrollTop + targetRect.bottom - viewportRect.bottom) + return true + } + + if (position.align === "center") { + const targetHeight = Math.max(0, targetRect.bottom - targetRect.top) + viewport.scrollTop = Math.max( + 0, + viewport.scrollTop + targetRect.top - viewportRect.top - (viewport.clientHeight - targetHeight) / 2, + ) + return true + } + + viewport.scrollTop = Math.max(0, viewport.scrollTop + targetRect.top - viewportRect.top) + return true +} + +export function restoreTimelineSafePosition(args: { + viewport: HTMLElement | undefined + position: TimelineSafePosition + bottomSentinel?: HTMLElement | null +}): TimelineAnchorRestoreResult { + if (!args.viewport) return { ok: false, reason: "viewport_missing" } + + if (args.position.kind === "latest") { + restoreLatest(args.viewport, args.bottomSentinel) + return { ok: true, restoredTo: args.position } + } + + if (args.position.kind === "reading") { + if (!args.position.anchorMessageID) return { ok: false, reason: "invalid_anchor" } + return restoreReading(args.viewport, args.position) + ? { ok: true, restoredTo: args.position } + : { ok: false, reason: "anchor_not_mounted" } + } + + if (!args.position.messageID) return { ok: false, reason: "invalid_anchor" } + return restoreTarget(args.viewport, args.position) + ? { ok: true, restoredTo: args.position } + : { ok: false, reason: "anchor_not_mounted" } +} From a03d5b7fb2c65293eb513c36497ae72a9e1ce557 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 20:57:20 +0800 Subject: [PATCH 03/11] feat: expose timeline scroll intents --- ...session-timeline-scroll-controller.test.ts | 61 +++++++++++++++++++ .../session-timeline-scroll-controller.ts | 24 ++++++++ .../ui/src/components/scroll-view.test.ts | 40 +++++++++++- packages/ui/src/components/scroll-view.tsx | 57 ++++++++++++++++- 4 files changed, 178 insertions(+), 4 deletions(-) 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 e29f41827..922aedacc 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 @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { + classifyTimelineScrollGesture, createSessionTimelineScrollController, createTimelineScrollControllerDiagnostic, type TimelineScrollDiagnosticEvent, @@ -304,3 +305,63 @@ describe("session timeline scroll controller", () => { }) }) }) + +describe("classifyTimelineScrollGesture", () => { + test("classifies weak trackpad movement without leaving nested scroll context", () => { + expect( + classifyTimelineScrollGesture({ + deltaY: -24, + viewportHeight: 800, + nestedScrollable: false, + atNestedBoundary: false, + }), + ).toEqual({ + direction: "up", + strength: "weak", + nestedScrollable: false, + }) + }) + + test("classifies strong upward movement by viewport-aware threshold", () => { + expect( + classifyTimelineScrollGesture({ + deltaY: -240, + viewportHeight: 800, + nestedScrollable: false, + atNestedBoundary: false, + }), + ).toEqual({ + direction: "up", + strength: "strong", + nestedScrollable: false, + }) + }) + + test("keeps nested scroll gestures out of main timeline intent until the nested boundary is reached", () => { + expect( + classifyTimelineScrollGesture({ + deltaY: 240, + viewportHeight: 800, + nestedScrollable: true, + atNestedBoundary: false, + }), + ).toEqual({ + direction: "down", + strength: "strong", + nestedScrollable: true, + }) + + expect( + classifyTimelineScrollGesture({ + deltaY: 240, + viewportHeight: 800, + nestedScrollable: true, + atNestedBoundary: true, + }), + ).toEqual({ + direction: "down", + strength: "strong", + nestedScrollable: false, + }) + }) +}) 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 585f0417a..a0d5850b1 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -181,6 +181,30 @@ export type SessionTimelineScrollController = { detach: (owner: { sessionOwner: string; viewportOwner: string }) => TimelineScrollControllerResult } +export type TimelineGestureClassification = { + direction: "up" | "down" + strength: "weak" | "strong" + nestedScrollable: boolean +} + +const STRONG_GESTURE_MIN_PX = 160 +const STRONG_GESTURE_VIEWPORT_RATIO = 0.25 + +export function classifyTimelineScrollGesture(input: { + deltaY: number + viewportHeight: number + nestedScrollable: boolean + atNestedBoundary: boolean +}): TimelineGestureClassification { + const threshold = Math.max(STRONG_GESTURE_MIN_PX, input.viewportHeight * STRONG_GESTURE_VIEWPORT_RATIO) + const nestedScrollable = input.nestedScrollable && !input.atNestedBoundary + return { + direction: input.deltaY < 0 ? "up" : "down", + strength: Math.abs(input.deltaY) >= threshold ? "strong" : "weak", + nestedScrollable, + } +} + const noRecovery: TimelineRecovery = { type: "none" } function cloneState(state: TimelineScrollControllerState): TimelineScrollControllerState { diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index d28b51fea..90db7eb4d 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { scrollKey } from "./scroll-view" +import { scrollKey, scrollViewKeyboardIntent, scrollViewMetrics } from "./scroll-view" describe("scrollKey", () => { test("maps plain navigation keys", () => { @@ -17,3 +17,41 @@ describe("scrollKey", () => { expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined() }) }) + +describe("scrollViewKeyboardIntent", () => { + test("reports the key before ScrollView performs mechanical scrolling", () => { + expect(scrollViewKeyboardIntent({ key: "Home", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })) + .toEqual({ + type: "keyboard_scroll", + key: "Home", + }) + expect( + scrollViewKeyboardIntent({ key: "ArrowDown", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }), + ).toEqual({ + type: "keyboard_scroll", + key: "ArrowDown", + }) + }) + + test("does not report modified navigation keys", () => { + expect( + scrollViewKeyboardIntent({ key: "Home", altKey: false, ctrlKey: false, metaKey: true, shiftKey: false }), + ).toBeUndefined() + }) +}) + +describe("scrollViewMetrics", () => { + test("captures viewport metrics for scrollbar drag intents", () => { + const viewport = { + scrollTop: 120, + scrollHeight: 900, + clientHeight: 400, + } as HTMLElement + + expect(scrollViewMetrics(viewport)).toEqual({ + scrollTop: 120, + scrollHeight: 900, + clientHeight: 400, + }) + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 190b23e51..8a2fc160a 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -6,8 +6,25 @@ import { useI18n } from "../context/i18n" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + onScrollIntent?: (intent: ScrollViewScrollIntent) => void } +export type ScrollViewKeyboardScrollKey = "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown" | "Home" | "End" + +export type ScrollViewScrollIntent = + | { + type: "keyboard_scroll" + key: ScrollViewKeyboardScrollKey + } + | { + type: "scrollbar_drag_start" | "scrollbar_drag_end" + metrics: { + scrollTop: number + scrollHeight: number + clientHeight: number + } + } + export const scrollKey = (event: Pick) => { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return @@ -27,6 +44,33 @@ export const scrollKey = (event: Pick, +): ScrollViewScrollIntent | undefined => { + const next = scrollKey(event) + if (!next) return + switch (next) { + case "page-down": + return { type: "keyboard_scroll", key: "PageDown" } + case "page-up": + return { type: "keyboard_scroll", key: "PageUp" } + case "home": + return { type: "keyboard_scroll", key: "Home" } + case "end": + return { type: "keyboard_scroll", key: "End" } + case "up": + return { type: "keyboard_scroll", key: "ArrowUp" } + case "down": + return { type: "keyboard_scroll", key: "ArrowDown" } + } +} + +export const scrollViewMetrics = (viewport: HTMLElement) => ({ + scrollTop: viewport.scrollTop, + scrollHeight: viewport.scrollHeight, + clientHeight: viewport.clientHeight, +}) + export function ScrollView(props: ScrollViewProps) { const i18n = useI18n() const merged = mergeProps({ orientation: "vertical" }, props) @@ -43,6 +87,7 @@ export function ScrollView(props: ScrollViewProps) { "onPointerDown", "onClick", "onKeyDown", + "onScrollIntent", ], ) @@ -109,6 +154,7 @@ export function ScrollView(props: ScrollViewProps) { const onThumbPointerDown = (e: PointerEvent) => { e.preventDefault() e.stopPropagation() + events.onScrollIntent?.({ type: "scrollbar_drag_start", metrics: scrollViewMetrics(viewportRef) }) setState("isDragging", true) startY = e.clientY startScrollTop = viewportRef.scrollTop @@ -127,15 +173,18 @@ export function ScrollView(props: ScrollViewProps) { } } - const onPointerUp = (e: PointerEvent) => { + const finishDrag = (e: PointerEvent) => { setState("isDragging", false) thumbRef.releasePointerCapture(e.pointerId) thumbRef.removeEventListener("pointermove", onPointerMove) - thumbRef.removeEventListener("pointerup", onPointerUp) + thumbRef.removeEventListener("pointerup", finishDrag) + thumbRef.removeEventListener("pointercancel", finishDrag) + events.onScrollIntent?.({ type: "scrollbar_drag_end", metrics: scrollViewMetrics(viewportRef) }) } thumbRef.addEventListener("pointermove", onPointerMove) - thumbRef.addEventListener("pointerup", onPointerUp) + thumbRef.addEventListener("pointerup", finishDrag) + thumbRef.addEventListener("pointercancel", finishDrag) } // Keybinds implementation @@ -150,6 +199,8 @@ export function ScrollView(props: ScrollViewProps) { const next = scrollKey(e) if (!next) return + const intent = scrollViewKeyboardIntent(e) + if (intent) events.onScrollIntent?.(intent) const scrollAmount = viewportRef.clientHeight * 0.8 const lineAmount = 40 From a0961045b3fffc8f8a0055efd5f600ebda6a5fd4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 20:59:53 +0800 Subject: [PATCH 04/11] feat: passively observe timeline scroll controller --- packages/app/src/pages/session.tsx | 2 + .../src/pages/session/message-timeline.tsx | 98 ++++++++++++++++--- .../src/pages/session/session-main-view.tsx | 4 + .../use-session-timeline-interaction.ts | 52 ++++++++++ .../src/main/renderer-diagnostics.test.ts | 59 +++++++++++ .../src/main/renderer-diagnostics.ts | 19 ++++ 6 files changed, 223 insertions(+), 11 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 87e5b4c92..55f81c4bc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -647,6 +647,8 @@ export default function Page() { markScrollGesture={timelineInteraction.markScrollGesture} hasScrollGesture={activeMessage.hasScrollGesture} markUserScroll={activeMessage.markUserScroll} + onTimelineScrollIntent={timelineInteraction.onTimelineScrollIntent} + onTimelineScrollObservation={timelineInteraction.onTimelineScrollObservation} historyWindow={historyWindow} centered={centered()} setContentRef={scrollDock.setContentRef} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b16bb06b2..8e715f761 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -10,12 +10,19 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" -import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { ScrollView, type ScrollViewScrollIntent } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { collectTimelineScrollMetrics } from "@/pages/session/session-timeline-scroll-anchors" +import { + classifyTimelineScrollGesture, + type TimelineScrollIntent, + type TimelineScrollMetrics, + type TimelineScrollObservation, +} from "@/pages/session/session-timeline-scroll-controller" import { taskDescription } from "@/pages/session/task-description" import { turnFetchSignature, @@ -118,29 +125,65 @@ const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { return nested } -const markBoundaryGesture = (input: { +const boundaryGesture = (input: { root: HTMLDivElement target: EventTarget | null delta: number - onMarkScrollGesture: (target?: EventTarget | null) => void }) => { const target = boundaryTarget(input.root, input.target) - if (target === input.root) { - input.onMarkScrollGesture(input.root) - return - } - if ( - shouldMarkBoundaryGesture({ + if (target === input.root) return { nestedScrollable: false, atNestedBoundary: true } + return { + nestedScrollable: true, + atNestedBoundary: shouldMarkBoundaryGesture({ delta: input.delta, scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, - }) - ) { + }), + } +} + +const markBoundaryGesture = (input: { + root: HTMLDivElement + target: EventTarget | null + delta: number + onMarkScrollGesture: (target?: EventTarget | null) => void +}) => { + const boundary = boundaryGesture(input) + if (!boundary.nestedScrollable || boundary.atNestedBoundary) { input.onMarkScrollGesture(input.root) } } +const scrollViewMetricsToTimelineMetrics = (metrics: { + scrollTop: number + scrollHeight: number + clientHeight: number +}): TimelineScrollMetrics => { + const max = Math.max(0, metrics.scrollHeight - metrics.clientHeight) + const distanceFromBottom = Math.max(0, max - metrics.scrollTop) + return { + scrollTop: metrics.scrollTop, + scrollHeight: metrics.scrollHeight, + clientHeight: metrics.clientHeight, + distanceFromTop: metrics.scrollTop, + distanceFromBottom, + nearTop: metrics.scrollTop <= 12, + nearBottom: distanceFromBottom <= 2, + } +} + +const scrollViewIntentToTimelineIntent = (intent: ScrollViewScrollIntent): TimelineScrollIntent => { + if (intent.type === "keyboard_scroll") { + return { type: "keyboard_scroll", key: intent.key, source: "scroll_view" } + } + return { + type: intent.type, + source: "scroll_view", + metrics: scrollViewMetricsToTimelineMetrics(intent.metrics), + } +} + type StageConfig = { init: number batch: number @@ -255,6 +298,8 @@ export function MessageTimeline(props: { onUserScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void + onTimelineScrollIntent: (intent: TimelineScrollIntent) => void + onTimelineScrollObservation: (observation: TimelineScrollObservation) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -903,6 +948,9 @@ export function MessageTimeline(props: { { + props.onTimelineScrollIntent(scrollViewIntentToTimelineIntent(intent)) + }} onWheel={(e) => { const root = e.currentTarget const delta = normalizeWheelDelta({ @@ -911,6 +959,18 @@ export function MessageTimeline(props: { rootHeight: root.clientHeight, }) if (!delta) return + const boundary = boundaryGesture({ root, target: e.target, delta }) + const gesture = classifyTimelineScrollGesture({ + deltaY: delta, + viewportHeight: root.clientHeight, + nestedScrollable: boundary.nestedScrollable, + atNestedBoundary: boundary.atNestedBoundary, + }) + props.onTimelineScrollIntent({ + type: "wheel_scroll", + source: "timeline", + ...gesture, + }) markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) }} onTouchStart={(e) => { @@ -926,6 +986,18 @@ export function MessageTimeline(props: { if (!delta) return const root = e.currentTarget + const boundary = boundaryGesture({ root, target: e.target, delta }) + const gesture = classifyTimelineScrollGesture({ + deltaY: delta, + viewportHeight: root.clientHeight, + nestedScrollable: boundary.nestedScrollable, + atNestedBoundary: boundary.atNestedBoundary, + }) + props.onTimelineScrollIntent({ + type: "touch_scroll", + source: "timeline", + ...gesture, + }) markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) }} onTouchEnd={() => { @@ -942,6 +1014,10 @@ export function MessageTimeline(props: { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() const el = e.currentTarget + props.onTimelineScrollObservation({ + type: "scroll_sample", + metrics: collectTimelineScrollMetrics(el), + }) const max = Math.max(0, el.scrollHeight - el.clientHeight) pendingScrollSample = { scroll_top: el.scrollTop, diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx index 807674d63..f1200b342 100644 --- a/packages/app/src/pages/session/session-main-view.tsx +++ b/packages/app/src/pages/session/session-main-view.tsx @@ -38,6 +38,8 @@ export function SessionMainView(props: { markScrollGesture: TimelineProps["onMarkScrollGesture"] hasScrollGesture: TimelineProps["hasScrollGesture"] markUserScroll: TimelineProps["onUserScroll"] + onTimelineScrollIntent: TimelineProps["onTimelineScrollIntent"] + onTimelineScrollObservation: TimelineProps["onTimelineScrollObservation"] historyWindow: ReturnType centered: boolean setContentRef: TimelineProps["setContentRef"] @@ -149,6 +151,8 @@ export function SessionMainView(props: { onMarkScrollGesture={props.markScrollGesture} hasScrollGesture={props.hasScrollGesture} onUserScroll={props.markUserScroll} + onTimelineScrollIntent={props.onTimelineScrollIntent} + onTimelineScrollObservation={props.onTimelineScrollObservation} onTurnBackfillScroll={props.historyWindow.onScrollerScroll} onAutoScrollInteraction={props.autoScroll.handleInteraction} centered={props.centered} 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 83b6ae754..4ce542ab1 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -6,6 +6,11 @@ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { createSessionHistoryBackfill } from "@/pages/session/use-session-history-backfill" import { createSessionHistoryWindow } from "@/pages/session/use-session-history-window" import { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" +import { + createSessionTimelineScrollController, + type TimelineScrollIntent, + type TimelineScrollObservation, +} from "@/pages/session/session-timeline-scroll-controller" export function createSessionTimelineInteraction(input: { routeSessionID: () => string | undefined @@ -23,6 +28,43 @@ export function createSessionTimelineInteraction(input: { let clearMessageHash = () => {} let activeMessage!: ReturnType let historyBackfill: ReturnType | undefined + let scrollController = createSessionTimelineScrollController({ + sessionOwner: input.sessionKey(), + viewportOwner: `timeline:${input.sessionKey()}`, + routeSessionID: input.routeSessionID(), + visibleSessionID: input.sessionID(), + timelineSessionID: input.sessionID(), + emitDiagnostic: (event) => { + void emitRendererDiagnostic(event).catch(() => {}) + }, + }) + + const createScrollController = () => + createSessionTimelineScrollController({ + sessionOwner: input.sessionKey(), + viewportOwner: `timeline:${input.sessionKey()}`, + routeSessionID: input.routeSessionID(), + visibleSessionID: input.sessionID(), + timelineSessionID: input.sessionID(), + emitDiagnostic: (event) => { + void emitRendererDiagnostic(event).catch(() => {}) + }, + }) + + createEffect( + on( + () => [input.sessionKey(), input.sessionID()] as const, + () => { + const previous = scrollController.state() + scrollController.detach({ + sessionOwner: previous.sessionOwner, + viewportOwner: previous.viewportOwner, + }) + scrollController = createScrollController() + }, + { defer: true }, + ), + ) const scrollDock = createSessionScrollDock({ clearMessageHash: () => clearMessageHash(), @@ -97,6 +139,14 @@ export function createSessionTimelineInteraction(input: { activeMessage.navigateMessageByOffset(offset) } + const onTimelineScrollIntent = (intent: TimelineScrollIntent) => { + scrollController.intent(intent) + } + + const onTimelineScrollObservation = (observation: TimelineScrollObservation) => { + scrollController.observe(observation) + } + createEffect( on( () => [input.sessionID(), input.visibleUserMessages().at(-1)?.id, historyWindow.turnStart()] as const, @@ -143,5 +193,7 @@ export function createSessionTimelineInteraction(input: { setScrollRef: scrollDock.setScrollRef, markScrollGesture, navigateMessageByOffset, + onTimelineScrollIntent, + onTimelineScrollObservation, } } diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index a7cf9f70b..c4d5ab319 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -153,6 +153,65 @@ describe("renderer diagnostics sanitizer", () => { model: "deepseek.v4", }) }) + + test("accepts typed session timeline scroll controller diagnostics", () => { + const event = sanitizeRendererDiagnosticEvent( + { + name: "session.timeline.scroll_controller", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + mode_before: "following_latest", + mode_after: "following_latest", + intent_type: "submit", + intent_source: "scroll_view", + observation_type: "scroll_sample", + accepted: false, + recovery: true, + reason: "submit_restore_latest_after_top_reset", + anchor_kind: "latest", + anchor_message_id: "msg_latest", + submit_origin_mode: "following_latest", + near_top: true, + near_bottom: false, + near_anchor: false, + session_owner: "ses_owner", + viewport_owner: "viewport_owner", + coalesced_count: 2, + 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.scroll_controller", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + mode_before: "following_latest", + mode_after: "following_latest", + intent_type: "submit", + intent_source: "scroll_view", + observation_type: "scroll_sample", + accepted: false, + recovery: true, + reason: "submit_restore_latest_after_top_reset", + anchor_kind: "latest", + anchor_message_id: "msg_latest", + submit_origin_mode: "following_latest", + near_top: true, + near_bottom: false, + near_anchor: false, + session_owner: "ses_owner", + viewport_owner: "viewport_owner", + coalesced_count: 2, + }, + }) + expect(JSON.stringify(event)).not.toContain("do not keep me") + }) }) describe("renderer diagnostics recorder", () => { diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index 84d312444..f24e686f8 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -144,6 +144,25 @@ const eventDataFields = { "session.timeline.mount": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], "session.timeline.unmount": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], "session.timeline.visible": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], + "session.timeline.scroll_controller": [ + "mode_before", + "mode_after", + "intent_type", + "intent_source", + "observation_type", + "accepted", + "recovery", + "reason", + "anchor_kind", + "anchor_message_id", + "submit_origin_mode", + "near_top", + "near_bottom", + "near_anchor", + "session_owner", + "viewport_owner", + "coalesced_count", + ], "session.scroll.sample": [ "scroll_top", "scroll_height", From 444cee323b6e481873a481c77292a3216ad5da16 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 21:01:24 +0800 Subject: [PATCH 05/11] fix: restore latest after submit top reset --- packages/app/src/pages/session.tsx | 8 +-- .../src/pages/session/message-timeline.tsx | 13 ++-- .../use-session-timeline-interaction.ts | 63 +++++++++++++++++-- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 55f81c4bc..101127680 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -403,9 +403,7 @@ export default function Page() { const resumeScroll = timelineInteraction.resumeScroll const scheduleScrollState = timelineInteraction.scheduleScrollState const scrollDock = timelineInteraction.scrollDock - const resumeScrollIfFollowing = () => { - if (scrollDock.scroll.bottom) resumeScroll() - } + const submitLatest = timelineInteraction.submitLatest const setScrollRef = timelineInteraction.setScrollRef useSessionKeyboardFocus({ @@ -572,9 +570,9 @@ export default function Page() { onNewSessionWorktreeReset={newSessionWorktree.reset} onSubmit={() => { comments.clear() - resumeScrollIfFollowing() + submitLatest() }} - onResponseSubmit={resumeScrollIfFollowing} + onResponseSubmit={submitLatest} onModeChange={ctx?.onModeChange} selectedSkill={ctx?.selectedSkill} followup={ diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8e715f761..32e90cb39 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -19,6 +19,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/ import { collectTimelineScrollMetrics } from "@/pages/session/session-timeline-scroll-anchors" import { classifyTimelineScrollGesture, + type TimelineScrollControllerResult, type TimelineScrollIntent, type TimelineScrollMetrics, type TimelineScrollObservation, @@ -299,7 +300,7 @@ export function MessageTimeline(props: { onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void onTimelineScrollIntent: (intent: TimelineScrollIntent) => void - onTimelineScrollObservation: (observation: TimelineScrollObservation) => void + onTimelineScrollObservation: (observation: TimelineScrollObservation) => TimelineScrollControllerResult centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -1011,12 +1012,11 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) }} onScroll={(e) => { - props.onScheduleScrollState(e.currentTarget) - props.onTurnBackfillScroll() const el = e.currentTarget - props.onTimelineScrollObservation({ + const metrics = collectTimelineScrollMetrics(el) + const controllerResult = props.onTimelineScrollObservation({ type: "scroll_sample", - metrics: collectTimelineScrollMetrics(el), + metrics, }) const max = Math.max(0, el.scrollHeight - el.clientHeight) pendingScrollSample = { @@ -1043,6 +1043,9 @@ export function MessageTimeline(props: { }).catch(() => {}) }) } + if (!controllerResult.accepted) return + props.onScheduleScrollState(e.currentTarget) + props.onTurnBackfillScroll() if (!props.hasScrollGesture()) return props.onUserScroll() props.onAutoScrollHandleScroll() 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 4ce542ab1..019ba72ff 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -1,6 +1,7 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" -import { createEffect, on } from "solid-js" +import { createEffect, on, onCleanup } from "solid-js" import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" +import { restoreTimelineSafePosition } from "@/pages/session/session-timeline-scroll-anchors" import { createSessionActiveMessage } from "@/pages/session/use-session-active-message" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { createSessionHistoryBackfill } from "@/pages/session/use-session-history-backfill" @@ -8,6 +9,8 @@ import { createSessionHistoryWindow } from "@/pages/session/use-session-history- import { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" import { createSessionTimelineScrollController, + type TimelineRecovery, + type TimelineScrollControllerResult, type TimelineScrollIntent, type TimelineScrollObservation, } from "@/pages/session/session-timeline-scroll-controller" @@ -28,6 +31,7 @@ export function createSessionTimelineInteraction(input: { let clearMessageHash = () => {} let activeMessage!: ReturnType let historyBackfill: ReturnType | undefined + let recoveryFrame: number | undefined let scrollController = createSessionTimelineScrollController({ sessionOwner: input.sessionKey(), viewportOwner: `timeline:${input.sessionKey()}`, @@ -66,6 +70,14 @@ export function createSessionTimelineInteraction(input: { ), ) + const cancelRecoveryFrame = () => { + if (recoveryFrame === undefined) return + cancelAnimationFrame(recoveryFrame) + recoveryFrame = undefined + } + + onCleanup(cancelRecoveryFrame) + const scrollDock = createSessionScrollDock({ clearMessageHash: () => clearMessageHash(), clearActiveMessage: () => activeMessage?.clearActiveMessage(), @@ -113,8 +125,9 @@ export function createSessionTimelineInteraction(input: { }) const resumeLatest = () => { + const result = scrollController.intent({ type: "jump_latest", source: "button" }) historyWindow.resumeLatestWindow() - resumeScroll() + applyTimelineRecovery(result.recovery) } historyBackfill = createSessionHistoryBackfill({ @@ -139,12 +152,49 @@ export function createSessionTimelineInteraction(input: { activeMessage.navigateMessageByOffset(offset) } - const onTimelineScrollIntent = (intent: TimelineScrollIntent) => { - scrollController.intent(intent) + const applyTimelineRecovery = (recovery: TimelineRecovery) => { + if (recovery.type === "none") return + cancelRecoveryFrame() + const owner = scrollController.state() + recoveryFrame = requestAnimationFrame(() => { + recoveryFrame = undefined + const current = scrollController.state() + if (current.sessionOwner !== owner.sessionOwner || current.viewportOwner !== owner.viewportOwner) return + + if (recovery.type === "restore_latest") { + historyWindow.resumeLatestWindow() + resumeScroll() + return + } + + const viewport = scrollDock.scroller() + const restored = restoreTimelineSafePosition({ + viewport, + position: recovery.anchor, + }) + if (restored.ok && viewport) scrollDock.scheduleScrollState(viewport) + }) } - const onTimelineScrollObservation = (observation: TimelineScrollObservation) => { - scrollController.observe(observation) + const onTimelineScrollIntent = (intent: TimelineScrollIntent): TimelineScrollControllerResult => { + const result = scrollController.intent(intent) + applyTimelineRecovery(result.recovery) + return result + } + + const onTimelineScrollObservation = (observation: TimelineScrollObservation): TimelineScrollControllerResult => { + const result = scrollController.observe(observation) + applyTimelineRecovery(result.recovery) + return result + } + + const submitLatest = () => { + const result = scrollController.intent({ + type: "submit", + originMode: scrollController.state().mode, + }) + historyWindow.resumeLatestWindow() + applyTimelineRecovery(result.recovery) } createEffect( @@ -188,6 +238,7 @@ export function createSessionTimelineInteraction(input: { anchor, historyWindow, resumeScroll: resumeLatest, + submitLatest, scheduleScrollState: scrollDock.scheduleScrollState, scrollDock, setScrollRef: scrollDock.setScrollRef, From be778d51c61f5038103ba5ab6d1d2bd110055c61 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 21:06:01 +0800 Subject: [PATCH 06/11] refactor: route timeline anchors through scroll controller --- .../composer/session-composer-region.tsx | 10 ++++ .../session-timeline-scroll-controller.ts | 6 +- .../session/use-session-hash-scroll.test.ts | 4 +- .../pages/session/use-session-hash-scroll.ts | 7 +-- .../session/use-session-scroll-dock.test.ts | 3 + .../pages/session/use-session-scroll-dock.ts | 28 ++++++++++ .../use-session-timeline-interaction.ts | 55 +++++++++++++++++-- .../src/main/renderer-diagnostics.ts | 8 ++- 8 files changed, 109 insertions(+), 12 deletions(-) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index d401f98af..978dc8622 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -102,6 +102,15 @@ export function SessionComposerRegion(props: { const dockSpring = useSpring(() => (dockOpen() ? 1 : 0), DOCK_MOTION) const dockProgress = createMemo(() => Math.max(0, Math.min(1, dockSpring()))) const dockMounted = createMemo(() => dockOpen() || dockProgress() > 0.001) + const dockKind = createMemo(() => { + if (props.state.questionRequest()) return "question" + if (props.state.permissionRequest()) return "permission" + if (dockMounted()) return "todo" + if (rolled()) return "revert" + if (props.followup?.items.length) return "followup" + if (showComposer()) return "prompt" + return "composer" + }) const openParent = () => { const id = parentID() @@ -114,6 +123,7 @@ export function SessionComposerRegion(props: { ref={props.setPromptDockRef} data-component="session-prompt-dock" data-variant={home() ? "home" : "session"} + data-dock-kind={dockKind()} classList={{ "w-full flex flex-col justify-center items-center pointer-events-none": true, "absolute inset-x-0 bottom-0 pb-6": !home(), 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 a0d5850b1..29f749a8d 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -503,7 +503,11 @@ export function createSessionTimelineScrollController( if (state.mode === "reading_history" && state.lastSafePosition.kind === "reading") { const reason = - observation.type === "content_resize" ? "content_resize_preserve_reading" : "reading_anchor_preserved" + observation.type === "content_resize" + ? "content_resize_preserve_reading" + : observation.type === "dock_resize" + ? "dock_resize_preserve_anchor" + : "reading_anchor_preserved" return result({ before, observation, diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts index fa0a11a24..7fefed6c6 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.test.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -36,8 +36,8 @@ describe("useSessionHashScroll", () => { const sessionSource = await Bun.file(new URL("../session.tsx", import.meta.url)).text() expect(hashSource).toContain("onMessageNavigation") - expect(hashSource).toContain("input.onMessageNavigation?.()") - expect(timelineSource).toContain("onMessageNavigation: scrollDock.cancelBottomFollowLock") + expect(hashSource).toContain("input.onMessageNavigation?.(message.id)") + expect(timelineSource).toContain("type: \"target_message\"") expect(timelineSource).toContain("const navigateMessageByOffset") expect(timelineSource).toContain("scrollDock.cancelBottomFollowLock()") expect(sessionSource).toContain("markScrollGesture: timelineInteraction.markScrollGesture") diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 7e58f55bd..52d54ae40 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -22,7 +22,7 @@ export const useSessionHashScroll = (input: { anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void consumePendingMessage: (key: string) => string | undefined - onMessageNavigation?: () => void + onMessageNavigation?: (messageID: string) => void onMessageHashCleared?: () => void }) => { const visibleUserMessages = createMemo(() => input.visibleUserMessages()) @@ -91,7 +91,7 @@ export const useSessionHashScroll = (input: { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { cancel() - input.onMessageNavigation?.() + input.onMessageNavigation?.(message.id) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -124,7 +124,7 @@ export const useSessionHashScroll = (input: { const messageId = messageIdFromHash(hash) if (messageId) { - input.onMessageNavigation?.() + input.onMessageNavigation?.(messageId) input.autoScroll.pause() const msg = messageById().get(messageId) if (msg) { @@ -136,7 +136,6 @@ export const useSessionHashScroll = (input: { const target = document.getElementById(hash) if (target) { - input.onMessageNavigation?.() input.autoScroll.pause() scrollToElement(target, behavior) return 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 7c898e716..63e912bab 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 @@ -241,6 +241,7 @@ describe("session scroll dock", () => { const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height") const promptDock = makeMeasuredDiv(120) const events: Array<{ + dockKind: "composer" | "question" | "permission" | "todo" | "followup" | "revert" | "prompt" composerHeight: number previousComposerHeight: number scrollTop?: number @@ -270,12 +271,14 @@ describe("session scroll dock", () => { expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("220px") expect(events).toEqual([ { + dockKind: "composer", composerHeight: 120, previousComposerHeight: 0, scrollTop: 600, distanceFromBottom: 0, }, { + dockKind: "composer", composerHeight: 220, previousComposerHeight: 120, scrollTop: 600, 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 ab019ec04..4e3d27149 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -83,11 +83,16 @@ export function createSessionScrollDock(input: { clearActiveMessage: () => void fill: () => void onDockHeightChange?: (event: { + dockKind: "composer" | "question" | "permission" | "todo" | "followup" | "revert" | "prompt" composerHeight: number previousComposerHeight: number scrollTop?: number distanceFromBottom?: number }) => void + onContentResize?: (event: { + scrollTop?: number + distanceFromBottom?: number + }) => void }) { const autoScroll = createAutoScroll({ working: () => true, @@ -203,6 +208,12 @@ export function createSessionScrollDock(input: { if (el && scroller) scheduleScrollState(scroller) if (!el) return contentObserver = new ResizeObserver(() => { + input.onContentResize?.({ + scrollTop: scroller?.scrollTop, + distanceFromBottom: scroller + ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + : undefined, + }) if (scroller) scheduleScrollState(scroller) input.fill() restoreBottomIfLocked() @@ -212,6 +223,7 @@ export function createSessionScrollDock(input: { const updateDockHeight = (next: number) => { const previousDockHeight = dockHeight + const dockKind = promptDockKind() const scrollTop = scroller?.scrollTop const distanceFromBottom = scroller ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop @@ -229,6 +241,7 @@ export function createSessionScrollDock(input: { if (dockHeight !== previousDockHeight) { try { input.onDockHeightChange?.({ + dockKind, composerHeight: dockHeight, previousComposerHeight: previousDockHeight, scrollTop, @@ -241,6 +254,21 @@ export function createSessionScrollDock(input: { } const measurePromptDockHeight = () => Math.ceil(promptDock?.getBoundingClientRect().height ?? 0) + const promptDockKind = () => { + const value = promptDock?.dataset.dockKind + if ( + value === "composer" || + value === "question" || + value === "permission" || + value === "todo" || + value === "followup" || + value === "revert" || + value === "prompt" + ) { + return value + } + return "composer" + } const setPromptDockRef = (el: HTMLDivElement | undefined) => { promptDockObserver?.disconnect() 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 019ba72ff..ab5bd9b4e 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -1,7 +1,11 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" import { createEffect, on, onCleanup } from "solid-js" import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" -import { restoreTimelineSafePosition } from "@/pages/session/session-timeline-scroll-anchors" +import { + collectTimelineScrollMetrics, + restoreTimelineSafePosition, + sampleTimelineSafePosition, +} from "@/pages/session/session-timeline-scroll-anchors" import { createSessionActiveMessage } from "@/pages/session/use-session-active-message" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { createSessionHistoryBackfill } from "@/pages/session/use-session-history-backfill" @@ -78,17 +82,37 @@ export function createSessionTimelineInteraction(input: { onCleanup(cancelRecoveryFrame) - const scrollDock = createSessionScrollDock({ + let scrollDock!: ReturnType + scrollDock = createSessionScrollDock({ clearMessageHash: () => clearMessageHash(), clearActiveMessage: () => activeMessage?.clearActiveMessage(), fill: () => historyBackfill?.fill(), + onContentResize: () => { + const viewport = scrollDock.scroller() + if (!viewport) return + onTimelineScrollObservation({ + type: "content_resize", + metrics: collectTimelineScrollMetrics(viewport), + }) + }, onDockHeightChange: (event) => { + const viewport = scrollDock.scroller() + if (viewport) { + onTimelineScrollObservation({ + type: "dock_resize", + dockKind: event.dockKind, + previousDockHeight: event.previousComposerHeight, + nextDockHeight: event.composerHeight, + metrics: collectTimelineScrollMetrics(viewport), + }) + } void emitRendererDiagnostic({ name: "session.layout.composer_dock", route_session_id: input.routeSessionID(), visible_session_id: input.sessionID(), timeline_session_id: input.sessionID(), data: { + dock_kind: event.dockKind, composer_height: event.composerHeight, previous_composer_height: event.previousComposerHeight, scroll_top: event.scrollTop, @@ -183,7 +207,23 @@ export function createSessionTimelineInteraction(input: { } const onTimelineScrollObservation = (observation: TimelineScrollObservation): TimelineScrollControllerResult => { - const result = scrollController.observe(observation) + let next = observation + if (observation.type === "scroll_sample" && !observation.safePosition) { + const viewport = scrollDock.scroller() + if (viewport) { + next = { + ...observation, + safePosition: sampleTimelineSafePosition({ + viewport, + mode: scrollController.state().mode, + renderedStart: historyWindow.turnStart(), + renderedCount: historyWindow.renderedUserMessages().length, + newestMessageID: input.visibleUserMessages().at(-1)?.id, + }), + } + } + } + const result = scrollController.observe(next) applyTimelineRecovery(result.recovery) return result } @@ -226,7 +266,14 @@ export function createSessionTimelineInteraction(input: { anchor, scheduleScrollState: scrollDock.scheduleScrollState, consumePendingMessage: input.consumePendingMessage, - onMessageNavigation: scrollDock.cancelBottomFollowLock, + onMessageNavigation: (messageID) => { + scrollDock.cancelBottomFollowLock() + onTimelineScrollIntent({ + type: "target_message", + messageID, + align: "nearest", + }) + }, onMessageHashCleared: () => historyWindow.clearHashTarget(), }) clearMessageHash = hashScroll.clearMessageHash diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index f24e686f8..6a22a09f2 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -173,7 +173,13 @@ const eventDataFields = { "visible_first_message_id", "visible_last_message_id", ], - "session.layout.composer_dock": ["composer_height", "previous_composer_height", "scroll_top", "distance_from_bottom"], + "session.layout.composer_dock": [ + "dock_kind", + "composer_height", + "previous_composer_height", + "scroll_top", + "distance_from_bottom", + ], "session.data.refresh": ["phase", "message_count", "part_count", "duration_ms", "cache_present"], "renderer.perf.sample": [ "fps", From 41d3cf38509cfc3ff4008afdb8b9250cf68e5b82 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 21:07:25 +0800 Subject: [PATCH 07/11] test: cover submit top reset controller recovery --- .../session-renderer-diagnostics.spec.ts | 28 +++++++++++++++++++ .../src/pages/session/message-timeline.tsx | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts index c6773a78c..19ffd97fe 100644 --- a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts +++ b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts @@ -145,6 +145,25 @@ async function resetTimelineToTop(page: Page) { expect(found, "session timeline viewport should exist").toBe(true) } +async function markTimelinePointerGesture(page: Page) { + const found = await page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return false + viewport.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + }), + ) + return true + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) + expect(found, "session timeline viewport should exist").toBe(true) +} + async function sendVisiblePrompt(input: { page: Page; text: string }) { const prompt = input.page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -236,6 +255,7 @@ test("captures renderer diagnostics while guarding send scroll position", async const promptText = `diagnostics guard ${Date.now()}` await sendVisiblePrompt({ page, text: promptText }) await expect(page.locator(sessionMessageItemSelector).last()).toContainText(promptText, { timeout: 30_000 }) + await markTimelinePointerGesture(page) await resetTimelineToTop(page) await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80) @@ -254,6 +274,14 @@ test("captures renderer diagnostics while guarding send scroll position", async const events = await readRendererDiagnostics(page) expect(events.some((event) => event.name === "session.action.submit")).toBe(true) + expect( + events.some( + (event) => + event.name === "session.timeline.scroll_controller" && + event.data?.accepted === false && + event.data?.reason === "submit_restore_latest_after_top_reset", + ), + ).toBe(true) expect(events.some((event) => event.name === "session.timeline.mount")).toBe(true) expect(events.some((event) => event.name === "session.timeline.visible")).toBe(true) expect(events.filter((event) => event.name === "session.timeline.mount")).toHaveLength(1) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 32e90cb39..73b6d6103 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1018,6 +1018,7 @@ export function MessageTimeline(props: { type: "scroll_sample", metrics, }) + if (!controllerResult.accepted) return const max = Math.max(0, el.scrollHeight - el.clientHeight) pendingScrollSample = { scroll_top: el.scrollTop, @@ -1043,7 +1044,6 @@ export function MessageTimeline(props: { }).catch(() => {}) }) } - if (!controllerResult.accepted) return props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() if (!props.hasScrollGesture()) return From cf75fbd70fd1129610052ac28d7d704443189e73 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 21:09:09 +0800 Subject: [PATCH 08/11] test: update scroll position submit expectation --- packages/app/e2e/session/session-scroll-position.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/e2e/session/session-scroll-position.spec.ts b/packages/app/e2e/session/session-scroll-position.spec.ts index c6950eb1e..065407a1f 100644 --- a/packages/app/e2e/session/session-scroll-position.spec.ts +++ b/packages/app/e2e/session/session-scroll-position.spec.ts @@ -399,9 +399,8 @@ test("keeps the latest turn in view when sending from an old message hash", asyn await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeGreaterThan(100) const token = `scroll_latest_${Date.now()}` - const beforeCount = await page.locator(sessionMessageItemSelector).count() await sendVisiblePrompt({ page, text: `reply with ${token}` }) - await expect(page.locator(sessionMessageItemSelector)).toHaveCount(beforeCount + 1, { timeout: 30_000 }) + await expect(page.locator(sessionMessageItemSelector).last()).toContainText(token, { timeout: 30_000 }) await expect.poll(() => page.url()).not.toContain("#message-") await expect From 043c044965500017aad3e4e5bfb5368e6b196a2e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 22:00:00 +0800 Subject: [PATCH 09/11] fix: honor scrollbar drag after submit --- .../session-renderer-diagnostics.spec.ts | 106 ++++++++++ .../session-timeline-scroll-anchors.test.ts | 25 +++ .../session-timeline-scroll-anchors.ts | 9 +- ...session-timeline-scroll-controller.test.ts | 189 +++++++++++++++++- .../session-timeline-scroll-controller.ts | 41 +++- .../use-session-timeline-interaction.ts | 20 +- 6 files changed, 373 insertions(+), 17 deletions(-) diff --git a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts index 19ffd97fe..a91df8c51 100644 --- a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts +++ b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts @@ -164,6 +164,50 @@ async function markTimelinePointerGesture(page: Page) { expect(found, "session timeline viewport should exist").toBe(true) } +async function timelineThumbBox(page: Page) { + return page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + const root = viewport?.closest(".scroll-view") + const thumb = root?.querySelector(".scroll-view__thumb") + if (!(thumb instanceof HTMLElement)) return null + const rect = thumb.getBoundingClientRect() + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + } + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) +} + +async function holdTimelineThumbDragBy(page: Page, deltaY: number) { + const box = await timelineThumbBox(page) + expect(box, "session timeline thumb should exist").not.toBeNull() + const x = box!.x + box!.width / 2 + const y = box!.y + Math.min(box!.height / 2, 12) + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x, y + deltaY, { steps: 8 }) +} + +async function dragTimelineThumbBy(page: Page, deltaY: number) { + await holdTimelineThumbDragBy(page, deltaY) + await page.mouse.up() +} + +async function waitForTimelineFrame(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }), + ) +} + async function sendVisiblePrompt(input: { page: Page; text: string }) { const prompt = input.page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -298,6 +342,68 @@ test("captures renderer diagnostics while guarding send scroll position", async }) }) +test("honors scrollbar thumb drag after submit instead of restoring latest", async ({ page, project }) => { + test.setTimeout(120_000) + + await installRendererDiagnosticsCapture(page) + await project.open() + const sdk = project.sdk + + await withSession(sdk, `e2e scrollbar drag latest ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await seedSessionTurns({ sdk, sessionID: session.id, count: 18 }) + + await project.gotoSession(session.id) + await expect(page.locator(sessionMessageItemSelector)).toHaveCount(10, { timeout: 30_000 }) + await scrollTimelineToBottom(page) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(40) + + const promptText = `scrollbar drag guard ${Date.now()}` + await sendVisiblePrompt({ page, text: promptText }) + await expect(page.locator(sessionMessageItemSelector).last()).toContainText(promptText, { timeout: 30_000 }) + + await holdTimelineThumbDragBy(page, -180) + try { + await resetTimelineToTop(page) + await expect + .poll(async () => { + const events = await readRendererDiagnostics(page) + return events.some( + (event) => + event.name === "session.timeline.scroll_controller" && + event.data?.observation_type === "scroll_sample" && + event.data?.near_top === true, + ) + }) + .toBe(true) + await waitForTimelineFrame(page) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeGreaterThan(200) + } finally { + await page.mouse.up().catch(() => {}) + } + + const events = await readRendererDiagnostics(page) + const dragIndex = events.findIndex( + (event) => + event.name === "session.timeline.scroll_controller" && event.data?.intent_type === "scrollbar_drag_start", + ) + expect(dragIndex).toBeGreaterThanOrEqual(0) + expect( + events + .slice(dragIndex) + .some( + (event) => + event.name === "session.timeline.scroll_controller" && + event.data?.accepted === false && + event.data?.reason === "submit_restore_latest_after_top_reset", + ), + ).toBe(false) + + await dragTimelineThumbBy(page, 10_000) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80) + }) +}) + test("keeps long timeline stable across worktree exit follow-up", async ({ page, project, llm }) => { test.setTimeout(180_000) 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 72a1d96c4..1efaf0659 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 @@ -132,6 +132,31 @@ describe("session timeline scroll anchors", () => { }) }) + test("keeps target message as the sampled anchor while targeting", () => { + const { viewport } = makeViewport({ + scrollTop: 260, + clientHeight: 400, + scrollHeight: 1200, + }) + appendMessage(viewport, "msg_anchor", { top: 124, bottom: 240 }) + + expect( + sampleTimelineSafePosition({ + viewport, + mode: "targeting_message", + renderedStart: 4, + renderedCount: 10, + newestMessageID: "msg_newest", + targetMessageID: "msg_target", + }), + ).toEqual({ + kind: "target_message", + messageID: "msg_target", + align: "nearest", + loadPolicy: "load_until_visible", + }) + }) + test("restores latest to bottom without sentinel", () => { const scroller = makeViewport({ scrollTop: 120, 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 065a42fb3..19cce60fe 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-anchors.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-anchors.ts @@ -16,10 +16,11 @@ function messageElementByID(viewport: HTMLElement, messageID: string) { function firstVisibleMessage(viewport: HTMLElement) { const viewportRect = viewport.getBoundingClientRect() - return messageElements(viewport) - .map((el) => ({ el, rect: el.getBoundingClientRect() })) - .filter((item) => item.rect.bottom > viewportRect.top && item.rect.top < viewportRect.bottom) - .sort((a, b) => a.rect.top - b.rect.top)[0] + for (const el of messageElements(viewport)) { + const rect = el.getBoundingClientRect() + if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) return { el, rect } + } + return undefined } export function collectTimelineScrollMetrics(viewport: HTMLElement): TimelineScrollMetrics { 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 922aedacc..733098cd2 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 @@ -46,6 +46,13 @@ const readingAnchor: TimelineSafePosition = { renderedCount: 10, } +const targetAnchor: TimelineSafePosition = { + kind: "target_message", + messageID: "msg_target", + align: "nearest", + loadPolicy: "load_until_visible", +} + function makeController() { const diagnostics: TimelineScrollDiagnosticEvent[] = [] const controller = createSessionTimelineScrollController({ @@ -129,6 +136,88 @@ describe("session timeline scroll controller", () => { expect(controller.state().lastSafePosition).toEqual(readingAnchor) }) + test("scrollbar drag after submit leaves latest protection before scroll samples", () => { + const { controller } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + + const dragResult = controller.intent({ + type: "scrollbar_drag_start", + source: "scroll_view", + metrics: bottomMetrics, + }) + const scrollResult = controller.observe({ + type: "scroll_sample", + metrics: topMetrics, + safePosition: readingAnchor, + }) + + expect(dragResult.reason).toBe("scrollbar_drag_started") + expect(scrollResult.accepted).toBe(true) + expect(scrollResult.recovery).toEqual({ type: "none" }) + expect(controller.state().mode).toBe("reading_history") + expect(controller.state().latestProtected).toBe(false) + expect(controller.state().lastSafePosition).toEqual(readingAnchor) + }) + + test("scrollbar drag after submit does not restore latest on resize observations", () => { + const { controller } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + controller.intent({ + type: "scrollbar_drag_start", + source: "scroll_view", + metrics: bottomMetrics, + }) + + const contentResult = controller.observe({ + type: "content_resize", + metrics: topMetrics, + }) + const dockResult = controller.observe({ + type: "dock_resize", + dockKind: "composer", + previousDockHeight: 64, + nextDockHeight: 96, + metrics: topMetrics, + }) + + expect(contentResult.recovery).toEqual({ type: "none" }) + expect(dockResult.recovery).toEqual({ type: "none" }) + expect(controller.state().mode).toBe("reading_history") + }) + + test("scrollbar drag end at bottom rejoins latest after leaving submit protection", () => { + const { controller } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + controller.intent({ + type: "scrollbar_drag_start", + source: "scroll_view", + metrics: bottomMetrics, + }) + + const result = controller.intent({ + type: "scrollbar_drag_end", + source: "scroll_view", + metrics: bottomMetrics, + }) + + expect(result.reason).toBe("explicit_bottom_navigation") + expect(result.recovery).toEqual({ type: "restore_latest", reason: "explicit_bottom_navigation" }) + expect(controller.state().mode).toBe("following_latest") + expect(controller.state().latestProtected).toBe(true) + }) + test("strong upward wheel intent can leave latest", () => { const { controller } = makeController() @@ -248,7 +337,58 @@ describe("session timeline scroll controller", () => { expect(controller.state().mode).toBe("targeting_message") }) - test("owner detach cancels pending recovery", () => { + test("target message anchor is not overwritten by ordinary scroll samples", () => { + const { controller } = makeController() + + controller.intent({ + type: "target_message", + messageID: "msg_target", + align: "nearest", + }) + controller.observe({ + type: "scroll_sample", + metrics: middleMetrics, + safePosition: readingAnchor, + }) + + const result = controller.observe({ + type: "content_resize", + metrics: middleMetrics, + }) + + expect(controller.state().lastSafePosition).toEqual(targetAnchor) + expect(result).toEqual({ + accepted: true, + recovery: { + type: "restore_anchor", + reason: "window_changed_preserve_target", + anchor: targetAnchor, + }, + reason: "window_changed_preserve_target", + }) + }) + + test("owner detached observation records normal detach reason", () => { + const { controller, diagnostics } = makeController() + + const result = controller.observe({ + type: "owner_detached", + sessionOwner: "ses_1", + viewportOwner: "viewport_1", + }) + + expect(result.accepted).toBe(true) + expect(result.reason).toBe("owner_detached") + expect(diagnostics.at(-1)).toMatchObject({ + data: { + accepted: true, + reason: "owner_detached", + observation_type: "owner_detached", + }, + }) + }) + + test("owner detached observation rejects owner mismatch", () => { const { controller, diagnostics } = makeController() controller.intent({ @@ -256,7 +396,8 @@ describe("session timeline scroll controller", () => { originMode: "following_latest", }) - const result = controller.detach({ + const result = controller.observe({ + type: "owner_detached", sessionOwner: "old_ses", viewportOwner: "old_viewport", }) @@ -268,6 +409,50 @@ describe("session timeline scroll controller", () => { data: { accepted: false, reason: "owner_mismatch_cancelled", + observation_type: "owner_detached", + }, + }) + }) + + test("matching owner detach records normal detach reason", () => { + const { controller, diagnostics } = makeController() + + const result = controller.detach({ + sessionOwner: "ses_1", + viewportOwner: "viewport_1", + }) + + expect(result.accepted).toBe(true) + expect(result.reason).toBe("owner_detached") + expect(diagnostics.at(-1)).toMatchObject({ + data: { + accepted: true, + reason: "owner_detached", + observation_type: "owner_detached", + }, + }) + }) + + test("owner detach cancels pending recovery when owner matches", () => { + const { controller, diagnostics } = makeController() + + controller.intent({ + type: "submit", + originMode: "following_latest", + }) + + const result = controller.detach({ + sessionOwner: "ses_1", + viewportOwner: "viewport_1", + }) + + expect(result.accepted).toBe(true) + expect(result.reason).toBe("owner_detached") + expect(controller.state().pendingRecovery).toEqual({ type: "none" }) + expect(diagnostics.at(-1)).toMatchObject({ + data: { + accepted: true, + reason: "owner_detached", }, }) }) 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 29f749a8d..59a9a5680 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -48,6 +48,7 @@ export type TimelineScrollReason = | "target_message_requested" | "target_load_exhausted_fallback" | "owner_mismatch_cancelled" + | "owner_detached" | "anchor_unrecoverable_fallback" export type TimelineRecovery = @@ -300,6 +301,17 @@ function updateSafePosition(state: TimelineScrollControllerState, safePosition: if (safePosition) state.lastSafePosition = safePosition } +function updateObservedSafePosition(state: TimelineScrollControllerState, safePosition: TimelineSafePosition | undefined) { + if ( + state.mode === "targeting_message" && + state.lastSafePosition.kind === "target_message" && + safePosition?.kind !== "target_message" + ) { + return + } + updateSafePosition(state, safePosition) +} + export function createSessionTimelineScrollController( options: SessionTimelineScrollControllerOptions, ): SessionTimelineScrollController { @@ -427,6 +439,8 @@ export function createSessionTimelineScrollController( } if (intent.type === "scrollbar_drag_start") { + state.mode = "reading_history" + state.latestProtected = false return result({ before, intent, @@ -448,20 +462,24 @@ export function createSessionTimelineScrollController( const before = cloneState(state) if (observation.type === "owner_detached") { - state.pendingRecovery = noRecovery - state.latestProtected = false + const ownerMatches = + observation.sessionOwner === state.sessionOwner && observation.viewportOwner === state.viewportOwner + if (ownerMatches) { + state.pendingRecovery = noRecovery + state.latestProtected = false + } return result({ before, observation, - accepted: true, + accepted: ownerMatches, recovery: noRecovery, - reason: "owner_mismatch_cancelled", + reason: ownerMatches ? "owner_detached" : "owner_mismatch_cancelled", }) } if (observation.type === "scroll_sample") { if (observation.metrics.nearBottom) { - updateSafePosition(state, observation.safePosition ?? { kind: "latest" }) + updateObservedSafePosition(state, observation.safePosition ?? { kind: "latest" }) if (state.lastIntent && isExplicitBottomIntent(state.lastIntent)) { state.mode = "following_latest" state.latestProtected = true @@ -491,7 +509,7 @@ export function createSessionTimelineScrollController( }) } - updateSafePosition(state, observation.safePosition) + updateObservedSafePosition(state, observation.safePosition) return result({ before, observation, @@ -555,8 +573,11 @@ export function createSessionTimelineScrollController( }, detach(owner) { const before = cloneState(state) - state.pendingRecovery = noRecovery - state.latestProtected = false + const ownerMatches = owner.sessionOwner === state.sessionOwner && owner.viewportOwner === state.viewportOwner + if (ownerMatches) { + state.pendingRecovery = noRecovery + state.latestProtected = false + } return result({ before, observation: { @@ -564,9 +585,9 @@ export function createSessionTimelineScrollController( sessionOwner: owner.sessionOwner, viewportOwner: owner.viewportOwner, }, - accepted: owner.sessionOwner === state.sessionOwner && owner.viewportOwner === state.viewportOwner, + accepted: ownerMatches, recovery: noRecovery, - reason: "owner_mismatch_cancelled", + reason: ownerMatches ? "owner_detached" : "owner_mismatch_cancelled", }) }, } 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 ab5bd9b4e..226d98fe1 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -171,6 +171,17 @@ export function createSessionTimelineInteraction(input: { activeMessage.markScrollGesture(target) } + const shouldCancelBottomFollowLockForIntent = (intent: TimelineScrollIntent) => { + 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" + } + if (intent.type === "wheel_scroll" || intent.type === "touch_scroll") { + return intent.direction === "up" && !intent.nestedScrollable + } + return false + } + const navigateMessageByOffset = (offset: number) => { scrollDock.cancelBottomFollowLock() activeMessage.navigateMessageByOffset(offset) @@ -201,6 +212,7 @@ export function createSessionTimelineInteraction(input: { } const onTimelineScrollIntent = (intent: TimelineScrollIntent): TimelineScrollControllerResult => { + if (shouldCancelBottomFollowLockForIntent(intent)) scrollDock.cancelBottomFollowLock() const result = scrollController.intent(intent) applyTimelineRecovery(result.recovery) return result @@ -211,14 +223,20 @@ export function createSessionTimelineInteraction(input: { if (observation.type === "scroll_sample" && !observation.safePosition) { const viewport = scrollDock.scroller() if (viewport) { + const controllerState = scrollController.state() + const targetMessageID = + controllerState.lastSafePosition.kind === "target_message" + ? controllerState.lastSafePosition.messageID + : undefined next = { ...observation, safePosition: sampleTimelineSafePosition({ viewport, - mode: scrollController.state().mode, + mode: controllerState.mode, renderedStart: historyWindow.turnStart(), renderedCount: historyWindow.renderedUserMessages().length, newestMessageID: input.visibleUserMessages().at(-1)?.id, + targetMessageID, }), } } From b2631036dadf20c0f6cbb6bde417abd0db91f922 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 22:11:53 +0800 Subject: [PATCH 10/11] fix: sync explicit timeline scroll intents --- .../session-renderer-diagnostics.spec.ts | 10 ++++++++++ .../app/src/pages/session/message-timeline.tsx | 12 +++++++++++- .../session-timeline-scroll-controller.test.ts | 18 ++++++++++++++++++ .../session-timeline-scroll-controller.ts | 5 +++-- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts index a91df8c51..1bed49bfa 100644 --- a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts +++ b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts @@ -398,6 +398,16 @@ test("honors scrollbar thumb drag after submit instead of restoring latest", asy event.data?.reason === "submit_restore_latest_after_top_reset", ), ).toBe(false) + expect( + events + .slice(dragIndex) + .some( + (event) => + event.name === "session.scroll.sample" && + event.data?.user_scrolled === true && + (numberData(event, "distance_from_bottom") ?? 0) > 200, + ), + ).toBe(true) await dragTimelineThumbBy(page, 10_000) await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 73b6d6103..8438be308 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -185,6 +185,11 @@ const scrollViewIntentToTimelineIntent = (intent: ScrollViewScrollIntent): Timel } } +const shouldMarkLegacyScrollIntent = (intent: ScrollViewScrollIntent) => { + if (intent.type === "keyboard_scroll") return true + return intent.type === "scrollbar_drag_start" +} + type StageConfig = { init: number batch: number @@ -312,6 +317,7 @@ export function MessageTimeline(props: { }) { let touchGesture: number | undefined let scrollSampleFrame: number | undefined + let viewportRef: HTMLDivElement | undefined let mounted = true let pendingScrollSample: | { @@ -948,8 +954,12 @@ export function MessageTimeline(props: { { + viewportRef = el + props.setScrollRef(el) + }} onScrollIntent={(intent) => { + if (shouldMarkLegacyScrollIntent(intent)) props.onMarkScrollGesture(viewportRef) props.onTimelineScrollIntent(scrollViewIntentToTimelineIntent(intent)) }} onWheel={(e) => { 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 733098cd2..e00db00b9 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 @@ -273,6 +273,24 @@ describe("session timeline scroll controller", () => { expect(controller.state().lastSafePosition).toEqual({ kind: "latest" }) }) + test("layout observations while following latest use a layout-preservation reason", () => { + const { controller } = makeController() + + const result = controller.observe({ + type: "content_resize", + metrics: bottomMetrics, + }) + + expect(result).toEqual({ + accepted: true, + recovery: { + type: "restore_latest", + reason: "follow_latest_preserved", + }, + reason: "follow_latest_preserved", + }) + }) + test("reading anchor is restored for after-layout resize observations", () => { 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 59a9a5680..bd348615f 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -36,6 +36,7 @@ export type TimelineScrollReason = | "submit_restore_latest_after_top_reset" | "explicit_top_navigation" | "explicit_bottom_navigation" + | "follow_latest_preserved" | "strong_upward_navigation" | "strong_downward_navigation" | "weak_scroll_observed" @@ -558,8 +559,8 @@ export function createSessionTimelineScrollController( before, observation, accepted: true, - recovery: { type: "restore_latest", reason: "explicit_bottom_navigation" }, - reason: "explicit_bottom_navigation", + recovery: { type: "restore_latest", reason: "follow_latest_preserved" }, + reason: "follow_latest_preserved", }) } From 2dc84dc36e57fea27038224f71b72d0e453653bf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 23:35:00 +0800 Subject: [PATCH 11/11] fix: address scroll review follow-ups --- packages/app/e2e/selectors.ts | 2 ++ .../session-renderer-diagnostics.spec.ts | 10 +++--- .../session/use-session-hash-scroll.test.ts | 10 ++++++ .../pages/session/use-session-hash-scroll.ts | 2 +- .../use-session-timeline-interaction.ts | 34 +++++++++---------- packages/ui/src/components/scroll-view.tsx | 2 ++ 6 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 16dced734..4da4a21c9 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -7,7 +7,9 @@ export const sessionComposerColumnSelector = '[data-component="session-composer- export const sessionTimelineColumnSelector = '[data-component="session-timeline-column"]' export const sessionTurnListSelector = '[data-slot="session-turn-list"]' export const sessionMessageItemSelector = "[data-message-id]" +export const scrollViewSelector = '[data-component="scroll-view"]' export const scrollViewportSelector = '[data-component="scroll-viewport"]' +export const scrollThumbSelector = '[data-component="scroll-thumb"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]' diff --git a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts index 1bed49bfa..88c5455a0 100644 --- a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts +++ b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts @@ -3,6 +3,8 @@ import { test, expect } from "../fixtures" import { withSession } from "../actions" import { promptSelector, + scrollThumbSelector, + scrollViewSelector, scrollViewportSelector, sessionMessageItemSelector, sessionTurnListSelector, @@ -166,11 +168,11 @@ async function markTimelinePointerGesture(page: Page) { async function timelineThumbBox(page: Page) { return page.evaluate( - ({ scrollViewportSelector, turnListSelector }) => { + ({ scrollThumbSelector, scrollViewSelector, scrollViewportSelector, turnListSelector }) => { const list = document.querySelector(turnListSelector) const viewport = list?.closest(scrollViewportSelector) - const root = viewport?.closest(".scroll-view") - const thumb = root?.querySelector(".scroll-view__thumb") + const root = viewport?.closest(scrollViewSelector) + const thumb = root?.querySelector(scrollThumbSelector) if (!(thumb instanceof HTMLElement)) return null const rect = thumb.getBoundingClientRect() return { @@ -180,7 +182,7 @@ async function timelineThumbBox(page: Page) { height: rect.height, } }, - { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + { scrollThumbSelector, scrollViewSelector, scrollViewportSelector, turnListSelector: sessionTurnListSelector }, ) } diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts index 7fefed6c6..5bfa9b24c 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.test.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -43,4 +43,14 @@ describe("useSessionHashScroll", () => { expect(sessionSource).toContain("markScrollGesture: timelineInteraction.markScrollGesture") expect(sessionSource).toContain("navigateMessageByOffset: timelineInteraction.navigateMessageByOffset") }) + + test("hash navigation emits once for already rendered messages", async () => { + const source = await Bun.file(new URL("./use-session-hash-scroll.ts", import.meta.url)).text() + + expect(source).toContain(`if (msg) { + scrollToMessage(msg, behavior) + return + } + input.onMessageNavigation?.(messageId)`) + }) }) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 52d54ae40..5260965e8 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -124,13 +124,13 @@ export const useSessionHashScroll = (input: { const messageId = messageIdFromHash(hash) if (messageId) { - input.onMessageNavigation?.(messageId) input.autoScroll.pause() const msg = messageById().get(messageId) if (msg) { scrollToMessage(msg, behavior) return } + input.onMessageNavigation?.(messageId) return } 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 226d98fe1..58fcdcd4f 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -36,17 +36,6 @@ export function createSessionTimelineInteraction(input: { let activeMessage!: ReturnType let historyBackfill: ReturnType | undefined let recoveryFrame: number | undefined - let scrollController = createSessionTimelineScrollController({ - sessionOwner: input.sessionKey(), - viewportOwner: `timeline:${input.sessionKey()}`, - routeSessionID: input.routeSessionID(), - visibleSessionID: input.sessionID(), - timelineSessionID: input.sessionID(), - emitDiagnostic: (event) => { - void emitRendererDiagnostic(event).catch(() => {}) - }, - }) - const createScrollController = () => createSessionTimelineScrollController({ sessionOwner: input.sessionKey(), @@ -58,11 +47,19 @@ export function createSessionTimelineInteraction(input: { void emitRendererDiagnostic(event).catch(() => {}) }, }) + let scrollController = createScrollController() + + const cancelRecoveryFrame = () => { + if (recoveryFrame === undefined) return + cancelAnimationFrame(recoveryFrame) + recoveryFrame = undefined + } createEffect( on( () => [input.sessionKey(), input.sessionID()] as const, () => { + cancelRecoveryFrame() const previous = scrollController.state() scrollController.detach({ sessionOwner: previous.sessionOwner, @@ -74,13 +71,14 @@ export function createSessionTimelineInteraction(input: { ), ) - const cancelRecoveryFrame = () => { - if (recoveryFrame === undefined) return - cancelAnimationFrame(recoveryFrame) - recoveryFrame = undefined - } - - onCleanup(cancelRecoveryFrame) + onCleanup(() => { + cancelRecoveryFrame() + const owner = scrollController.state() + scrollController.detach({ + sessionOwner: owner.sessionOwner, + viewportOwner: owner.viewportOwner, + }) + }) let scrollDock!: ReturnType scrollDock = createSessionScrollDock({ diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 8a2fc160a..f2c04fa28 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -236,6 +236,7 @@ export function ScrollView(props: ScrollViewProps) { return (
setState("isHovered", true)} @@ -275,6 +276,7 @@ export function ScrollView(props: ScrollViewProps) { ref={thumbRef} onPointerDown={onThumbPointerDown} class="scroll-view__thumb" + data-component="scroll-thumb" data-visible={isHovered() || isDragging()} data-dragging={isDragging()} style={{