From 1f3000a2b858387402c551b68f686a1817b92220 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 13:18:39 +0800 Subject: [PATCH 1/6] refactor(app): extract timeline staging helper --- .../session/session-timeline-staging.test.ts | 174 ++++++++++++++++++ .../pages/session/session-timeline-staging.ts | 100 ++++++++++ 2 files changed, 274 insertions(+) create mode 100644 packages/app/src/pages/session/session-timeline-staging.test.ts create mode 100644 packages/app/src/pages/session/session-timeline-staging.ts diff --git a/packages/app/src/pages/session/session-timeline-staging.test.ts b/packages/app/src/pages/session/session-timeline-staging.test.ts new file mode 100644 index 000000000..974c55193 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-staging.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test" + +const browserCheck = String.raw` +import { render } from "solid-js/web" +import { createSignal } from "solid-js" +import { createTimelineStaging } from "./src/pages/session/session-timeline-staging.ts" + +const assert = (condition, message) => { + if (!condition) throw new Error(message) +} + +const message = (id) => ({ + id: "msg_" + id, + role: "user", + time: { created: id }, +}) +const messages = (count) => Array.from({ length: count }, (_, index) => message(index)) +const ids = (list) => list.map((item) => item.id).join(",") + +const installAnimationFrameQueue = () => { + let nextID = 1 + const frames = new Map() + const canceled = [] + + globalThis.requestAnimationFrame = (callback) => { + const id = nextID++ + frames.set(id, callback) + return id + } + + globalThis.cancelAnimationFrame = (id) => { + canceled.push(id) + frames.delete(id) + } + + return { + canceled, + pending: () => frames.size, + pendingIDs: () => [...frames.keys()], + flushOne: () => { + const next = frames.entries().next() + if (next.done) return false + const [id, callback] = next.value + frames.delete(id) + callback(performance.now()) + return true + }, + } +} + +const mount = (factory) => { + const root = document.createElement("div") + document.body.append(root) + const dispose = render(factory, root) + return () => { + dispose() + root.remove() + } +} + +{ + const raf = installAnimationFrameQueue() + let staging + const dispose = mount(() => { + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 0, + messages: () => messages(14), + config: { init: 10, batch: 3 }, + }) + return null + }) + + assert(ids(staging.messages()) === ids(messages(14)), "non-windowed timeline should render all messages") + assert(staging.isStaging() === false, "non-windowed timeline should not stage") + assert(raf.pending() === 0, "non-windowed timeline should not schedule frames") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + const dispose = mount(() => { + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 6, + messages: () => messages(16), + config: { init: 10, batch: 3 }, + }) + return null + }) + + assert(ids(staging.messages()) === ids(messages(16).slice(6)), "history window should start at init size") + assert(staging.isStaging() === true, "history window should report active staging") + assert(raf.pending() === 1, "history window should schedule one frame") + assert(raf.flushOne() === true, "first staging frame should run") + assert(ids(staging.messages()) === ids(messages(16).slice(3)), "first frame should add one batch") + assert(staging.isStaging() === true, "staging should remain active before completion") + assert(raf.flushOne() === true, "second staging frame should run") + assert(ids(staging.messages()) === ids(messages(16)), "second frame should complete staging") + assert(staging.isStaging() === false, "completed staging should clear active state") + assert(raf.pending() === 0, "completed staging should not leave pending frames") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + let setCount + const dispose = mount(() => { + const [count, nextCount] = createSignal(13) + setCount = nextCount + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 3, + messages: () => messages(count()), + config: { init: 10, batch: 3 }, + }) + return null + }) + + assert(ids(staging.messages()) === ids(messages(13).slice(3)), "completed-session case should start windowed") + assert(raf.flushOne() === true, "completion frame should run") + assert(ids(staging.messages()) === ids(messages(13)), "completion frame should reveal all") + assert(staging.isStaging() === false, "completion should clear active state") + setCount(16) + assert(ids(staging.messages()) === ids(messages(16)), "completed session backfill should render immediately") + assert(staging.isStaging() === false, "completed session backfill should not restage") + assert(raf.pending() === 0, "completed session backfill should not schedule frames") + dispose() +} + +{ + const raf = installAnimationFrameQueue() + let staging + let setSessionKey + const dispose = mount(() => { + const [sessionKey, nextSessionKey] = createSignal("ses_1") + setSessionKey = nextSessionKey + staging = createTimelineStaging({ + sessionKey, + turnStart: () => 6, + messages: () => messages(16), + config: { init: 10, batch: 3 }, + }) + return null + }) + + const firstFrame = raf.pendingIDs()[0] + assert(firstFrame !== undefined, "initial history staging should schedule a frame") + setSessionKey("ses_2") + assert(raf.canceled.includes(firstFrame), "session switch should cancel the previous frame") + assert(raf.pending() === 1, "session switch should leave one new frame for the new session") + assert(ids(staging.messages()) === ids(messages(16).slice(6)), "new session should restart at init size") + assert(raf.flushOne() === true, "new session frame should run") + assert(ids(staging.messages()) === ids(messages(16).slice(3)), "new session frame should add one batch") + dispose() +} +` + +describe("createTimelineStaging", () => { + test("preserves browser staging behavior", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "--conditions=browser", "--preload", "./happydom.ts", "-e", browserCheck], + cwd: new URL("../../..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }) + + const output = `${new TextDecoder().decode(result.stdout)}${new TextDecoder().decode(result.stderr)}` + expect(output).toBe("") + expect(result.exitCode).toBe(0) + }) +}) diff --git a/packages/app/src/pages/session/session-timeline-staging.ts b/packages/app/src/pages/session/session-timeline-staging.ts new file mode 100644 index 000000000..2a27793b1 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-staging.ts @@ -0,0 +1,100 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +export type TimelineStageConfig = { + init: number + batch: number +} + +export type TimelineStageInput = { + sessionKey: () => string + turnStart: () => number + messages: () => UserMessage[] + config: TimelineStageConfig +} + +/** + * Defer-mounts small timeline windows so revealing older turns does not + * block first paint with a large DOM mount. + * + * Once staging completes for a session it never re-stages. Backfill and + * new messages render immediately. + */ +export function createTimelineStaging(input: TimelineStageInput) { + const [state, setState] = createStore({ + activeSession: "", + completedSession: "", + count: 0, + }) + + const stagedCount = createMemo(() => { + const total = input.messages().length + if (input.turnStart() <= 0) return total + if (state.completedSession === input.sessionKey()) return total + const init = Math.min(total, input.config.init) + if (state.count <= init) return init + if (state.count >= total) return total + return state.count + }) + + const stagedUserMessages = createMemo(() => { + const list = input.messages() + const count = stagedCount() + if (count >= list.length) return list + return list.slice(Math.max(0, list.length - count)) + }) + + let frame: number | undefined + const cancel = () => { + if (frame === undefined) return + cancelAnimationFrame(frame) + frame = undefined + } + + createEffect( + on( + () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, + ([sessionKey, isWindowed, total]) => { + cancel() + const shouldStage = + isWindowed && + total > input.config.init && + state.completedSession !== sessionKey && + state.activeSession !== sessionKey + if (!shouldStage) { + setState({ activeSession: "", count: total }) + return + } + + let count = Math.min(total, input.config.init) + setState({ activeSession: sessionKey, count }) + + const step = () => { + if (input.sessionKey() !== sessionKey) { + frame = undefined + return + } + const currentTotal = input.messages().length + count = Math.min(currentTotal, count + input.config.batch) + setState("count", count) + if (count >= currentTotal) { + setState({ completedSession: sessionKey, activeSession: "" }) + frame = undefined + return + } + frame = requestAnimationFrame(step) + } + frame = requestAnimationFrame(step) + }, + ), + ) + + const isStaging = createMemo(() => { + const key = input.sessionKey() + return state.activeSession === key && state.completedSession !== key + }) + + onCleanup(cancel) + return { messages: stagedUserMessages, isStaging } +} From 510653cb874412e81736be781b5d46bcda8f1709 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 13:48:14 +0800 Subject: [PATCH 2/6] refactor(app): wire timeline staging helper --- .../src/pages/session/message-timeline.tsx | 98 +------------------ 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0b07c6135..625207e86 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -24,6 +24,7 @@ import { type TimelineScrollMetrics, type TimelineScrollObservation, } from "@/pages/session/session-timeline-scroll-controller" +import { createTimelineStaging } from "@/pages/session/session-timeline-staging" import { taskDescription } from "@/pages/session/task-description" import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages" import { @@ -191,103 +192,6 @@ const shouldMarkLegacyScrollIntent = (intent: ScrollViewScrollIntent) => { return intent.type === "scrollbar_drag_start" } -type StageConfig = { - init: number - batch: number -} - -type TimelineStageInput = { - sessionKey: () => string - turnStart: () => number - messages: () => UserMessage[] - config: StageConfig -} - -/** - * Defer-mounts small timeline windows so revealing older turns does not - * block first paint with a large DOM mount. - * - * Once staging completes for a session it never re-stages — backfill and - * new messages render immediately. - */ -function createTimelineStaging(input: TimelineStageInput) { - const [state, setState] = createStore({ - activeSession: "", - completedSession: "", - count: 0, - }) - - const stagedCount = createMemo(() => { - const total = input.messages().length - if (input.turnStart() <= 0) return total - if (state.completedSession === input.sessionKey()) return total - const init = Math.min(total, input.config.init) - if (state.count <= init) return init - if (state.count >= total) return total - return state.count - }) - - const stagedUserMessages = createMemo(() => { - const list = input.messages() - const count = stagedCount() - if (count >= list.length) return list - return list.slice(Math.max(0, list.length - count)) - }) - - let frame: number | undefined - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - createEffect( - on( - () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, - ([sessionKey, isWindowed, total]) => { - cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey - if (!shouldStage) { - setState({ activeSession: "", count: total }) - return - } - - let count = Math.min(total, input.config.init) - setState({ activeSession: sessionKey, count }) - - const step = () => { - if (input.sessionKey() !== sessionKey) { - frame = undefined - return - } - const currentTotal = input.messages().length - count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) - if (count >= currentTotal) { - setState({ completedSession: sessionKey, activeSession: "" }) - frame = undefined - return - } - frame = requestAnimationFrame(step) - } - frame = requestAnimationFrame(step) - }, - ), - ) - - const isStaging = createMemo(() => { - const key = input.sessionKey() - return state.activeSession === key && state.completedSession !== key - }) - - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } -} - export function MessageTimeline(props: { sessionID: string sessionKey: string From e0eecc3e02e460c679a04900753697d228e4b699 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 13:30:46 +0800 Subject: [PATCH 3/6] refactor(app): extract timeline scroll intent helper --- .../session-timeline-scroll-intents.test.ts | 182 ++++++++++++++++++ .../session-timeline-scroll-intents.ts | 145 ++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 packages/app/src/pages/session/session-timeline-scroll-intents.test.ts create mode 100644 packages/app/src/pages/session/session-timeline-scroll-intents.ts diff --git a/packages/app/src/pages/session/session-timeline-scroll-intents.test.ts b/packages/app/src/pages/session/session-timeline-scroll-intents.test.ts new file mode 100644 index 000000000..d420de5c8 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-intents.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test" +import { + createTouchTimelineScrollIntent, + createWheelTimelineScrollIntent, + scrollViewIntentToTimelineIntent, + scrollViewMetricsToTimelineMetrics, + shouldMarkLegacyScrollIntent, + shouldMarkTimelineBoundaryGesture, + timelineBoundaryGesture, +} from "./session-timeline-scroll-intents" + +const setMetric = (element: HTMLElement, name: "clientHeight" | "scrollHeight" | "scrollTop", value: number) => { + Object.defineProperty(element, name, { configurable: true, value }) +} + +const makeRoot = (height = 500) => { + const root = document.createElement("div") + setMetric(root, "clientHeight", height) + return root as HTMLDivElement +} + +describe("scrollViewMetricsToTimelineMetrics", () => { + test("converts scroll view metrics into timeline metrics", () => { + expect( + scrollViewMetricsToTimelineMetrics({ + scrollTop: 40, + scrollHeight: 240, + clientHeight: 100, + }), + ).toEqual({ + scrollTop: 40, + scrollHeight: 240, + clientHeight: 100, + distanceFromTop: 40, + distanceFromBottom: 100, + nearTop: false, + nearBottom: false, + }) + }) + + test("clamps bottom distance and exposes top/bottom thresholds", () => { + expect(scrollViewMetricsToTimelineMetrics({ scrollTop: 0, scrollHeight: 80, clientHeight: 100 })).toMatchObject({ + distanceFromBottom: 0, + nearTop: true, + nearBottom: true, + }) + + expect(scrollViewMetricsToTimelineMetrics({ scrollTop: 98, scrollHeight: 200, clientHeight: 100 })).toMatchObject({ + distanceFromBottom: 2, + nearTop: false, + nearBottom: true, + }) + }) +}) + +describe("scrollViewIntentToTimelineIntent", () => { + test("keeps keyboard scroll intent discrete", () => { + expect(scrollViewIntentToTimelineIntent({ type: "keyboard_scroll", key: "Home" })).toEqual({ + type: "keyboard_scroll", + key: "Home", + source: "scroll_view", + }) + }) + + test("attaches timeline metrics to scrollbar intents", () => { + expect( + scrollViewIntentToTimelineIntent({ + type: "scrollbar_drag_start", + metrics: { scrollTop: 10, scrollHeight: 210, clientHeight: 100 }, + }), + ).toEqual({ + type: "scrollbar_drag_start", + source: "scroll_view", + metrics: { + scrollTop: 10, + scrollHeight: 210, + clientHeight: 100, + distanceFromTop: 10, + distanceFromBottom: 100, + nearTop: true, + nearBottom: false, + }, + }) + }) + + test("marks legacy scroll intents that still need gesture cancellation", () => { + expect(shouldMarkLegacyScrollIntent({ type: "keyboard_scroll", key: "ArrowUp" })).toBe(true) + expect( + shouldMarkLegacyScrollIntent({ + type: "scrollbar_drag_start", + metrics: { scrollTop: 0, scrollHeight: 100, clientHeight: 100 }, + }), + ).toBe(true) + expect( + shouldMarkLegacyScrollIntent({ + type: "scrollbar_drag_end", + metrics: { scrollTop: 0, scrollHeight: 100, clientHeight: 100 }, + }), + ).toBe(false) + }) +}) + +describe("timelineBoundaryGesture", () => { + test("treats main timeline gestures as boundary gestures", () => { + const root = makeRoot() + + const boundary = timelineBoundaryGesture({ root, target: root, delta: 20 }) + + expect(boundary).toEqual({ nestedScrollable: false, atNestedBoundary: true }) + expect(shouldMarkTimelineBoundaryGesture(boundary)).toBe(true) + }) + + test("does not mark nested scrollables that can consume the gesture", () => { + const root = makeRoot() + const nested = document.createElement("div") + nested.setAttribute("data-scrollable", "") + setMetric(nested, "scrollTop", 100) + setMetric(nested, "scrollHeight", 300) + setMetric(nested, "clientHeight", 100) + root.append(nested) + + const boundary = timelineBoundaryGesture({ root, target: nested, delta: 20 }) + + expect(boundary).toEqual({ nestedScrollable: true, atNestedBoundary: false }) + expect(shouldMarkTimelineBoundaryGesture(boundary)).toBe(false) + }) + + test("marks nested scrollables once they hit their boundary", () => { + const root = makeRoot() + const nested = document.createElement("div") + nested.setAttribute("data-scrollable", "") + setMetric(nested, "scrollTop", 0) + setMetric(nested, "scrollHeight", 300) + setMetric(nested, "clientHeight", 100) + root.append(nested) + + const boundary = timelineBoundaryGesture({ root, target: nested, delta: -20 }) + + expect(boundary).toEqual({ nestedScrollable: true, atNestedBoundary: true }) + expect(shouldMarkTimelineBoundaryGesture(boundary)).toBe(true) + }) +}) + +describe("timeline pointer intents", () => { + test("normalizes wheel gestures into timeline intents", () => { + const root = makeRoot(500) + + expect(createWheelTimelineScrollIntent({ root, target: root, deltaY: -1, deltaMode: 2 })).toEqual({ + delta: -500, + boundary: { nestedScrollable: false, atNestedBoundary: true }, + intent: { + type: "wheel_scroll", + source: "timeline", + direction: "up", + strength: "strong", + nestedScrollable: false, + }, + }) + }) + + test("returns undefined for zero wheel and touch deltas", () => { + const root = makeRoot() + + expect(createWheelTimelineScrollIntent({ root, target: root, deltaY: 0, deltaMode: 0 })).toBeUndefined() + expect(createTouchTimelineScrollIntent({ root, target: root, delta: 0 })).toBeUndefined() + }) + + test("normalizes touch deltas into timeline intents", () => { + const root = makeRoot() + + expect(createTouchTimelineScrollIntent({ root, target: root, delta: 24 })).toMatchObject({ + delta: 24, + boundary: { nestedScrollable: false, atNestedBoundary: true }, + intent: { + type: "touch_scroll", + source: "timeline", + direction: "down", + nestedScrollable: false, + }, + }) + }) +}) diff --git a/packages/app/src/pages/session/session-timeline-scroll-intents.ts b/packages/app/src/pages/session/session-timeline-scroll-intents.ts new file mode 100644 index 000000000..da4ce2f2c --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-intents.ts @@ -0,0 +1,145 @@ +import type { ScrollViewScrollIntent } from "@opencode-ai/ui/scroll-view" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture" +import { + classifyTimelineScrollGesture, + type TimelineScrollIntent, + type TimelineScrollMetrics, +} from "@/pages/session/session-timeline-scroll-controller" + +export type TimelineBoundaryGesture = { + nestedScrollable: boolean + atNestedBoundary: boolean +} + +export function timelineBoundaryTarget(root: HTMLElement, target: EventTarget | null) { + const current = target instanceof Element ? target : undefined + const nested = current?.closest("[data-scrollable]") + if (!nested || nested === root) return root + if (!(nested instanceof HTMLElement)) return root + return nested +} + +export function timelineBoundaryGesture(input: { + root: HTMLDivElement + target: EventTarget | null + delta: number +}): TimelineBoundaryGesture { + const target = timelineBoundaryTarget(input.root, input.target) + 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, + }), + } +} + +export function shouldMarkTimelineBoundaryGesture(boundary: TimelineBoundaryGesture) { + return !boundary.nestedScrollable || boundary.atNestedBoundary +} + +export function 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, + } +} + +export function 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), + } +} + +export function shouldMarkLegacyScrollIntent(intent: ScrollViewScrollIntent) { + if (intent.type === "keyboard_scroll") return true + return intent.type === "scrollbar_drag_start" +} + +export type TimelineGestureIntentResult = { + delta: number + boundary: TimelineBoundaryGesture + intent: TimelineScrollIntent +} + +export function createWheelTimelineScrollIntent(input: { + root: HTMLDivElement + target: EventTarget | null + deltaY: number + deltaMode: number +}): TimelineGestureIntentResult | undefined { + const delta = normalizeWheelDelta({ + deltaY: input.deltaY, + deltaMode: input.deltaMode, + rootHeight: input.root.clientHeight, + }) + if (!delta) return + + return createPointerTimelineScrollIntent({ + type: "wheel_scroll", + root: input.root, + target: input.target, + delta, + }) +} + +export function createTouchTimelineScrollIntent(input: { + root: HTMLDivElement + target: EventTarget | null + delta: number +}): TimelineGestureIntentResult | undefined { + if (!input.delta) return + return createPointerTimelineScrollIntent({ + type: "touch_scroll", + root: input.root, + target: input.target, + delta: input.delta, + }) +} + +function createPointerTimelineScrollIntent(input: { + type: "wheel_scroll" | "touch_scroll" + root: HTMLDivElement + target: EventTarget | null + delta: number +}): TimelineGestureIntentResult { + const boundary = timelineBoundaryGesture({ + root: input.root, + target: input.target, + delta: input.delta, + }) + const gesture = classifyTimelineScrollGesture({ + deltaY: input.delta, + viewportHeight: input.root.clientHeight, + nestedScrollable: boundary.nestedScrollable, + atNestedBoundary: boundary.atNestedBoundary, + }) + return { + delta: input.delta, + boundary, + intent: { + type: input.type, + source: "timeline", + ...gesture, + }, + } +} From 1b6320d8940a16d43834a3ea4c9f9c63a4f44a5b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 13:49:38 +0800 Subject: [PATCH 4/6] refactor(app): wire timeline scroll intent helper --- .../src/pages/session/message-timeline.tsx | 127 +++--------------- 1 file changed, 21 insertions(+), 106 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 625207e86..5abf573bc 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -10,20 +10,24 @@ 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, type ScrollViewScrollIntent } from "@opencode-ai/ui/scroll-view" +import { ScrollView } 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 TimelineScrollControllerResult, type TimelineScrollIntent, - type TimelineScrollMetrics, type TimelineScrollObservation, } from "@/pages/session/session-timeline-scroll-controller" +import { + createTouchTimelineScrollIntent, + createWheelTimelineScrollIntent, + scrollViewIntentToTimelineIntent, + shouldMarkLegacyScrollIntent, + shouldMarkTimelineBoundaryGesture, +} from "@/pages/session/session-timeline-scroll-intents" import { createTimelineStaging } from "@/pages/session/session-timeline-staging" import { taskDescription } from "@/pages/session/task-description" import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages" @@ -120,78 +124,6 @@ const messageComments = (parts: Part[]): MessageComment[] => export { taskDescription } -const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { - const current = target instanceof Element ? target : undefined - const nested = current?.closest("[data-scrollable]") - if (!nested || nested === root) return root - if (!(nested instanceof HTMLElement)) return root - return nested -} - -const boundaryGesture = (input: { - root: HTMLDivElement - target: EventTarget | null - delta: number -}) => { - const target = boundaryTarget(input.root, input.target) - 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), - } -} - -const shouldMarkLegacyScrollIntent = (intent: ScrollViewScrollIntent) => { - if (intent.type === "keyboard_scroll") return true - return intent.type === "scrollbar_drag_start" -} - export function MessageTimeline(props: { sessionID: string sessionKey: string @@ -870,26 +802,15 @@ export function MessageTimeline(props: { props.onTimelineScrollIntent(scrollViewIntentToTimelineIntent(intent)) }} onWheel={(e) => { - const root = e.currentTarget - const delta = normalizeWheelDelta({ + const result = createWheelTimelineScrollIntent({ + root: e.currentTarget, + target: e.target, deltaY: e.deltaY, deltaMode: e.deltaMode, - 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 }) + if (!result) return + props.onTimelineScrollIntent(result.intent) + if (shouldMarkTimelineBoundaryGesture(result.boundary)) props.onMarkScrollGesture(e.currentTarget) }} onTouchStart={(e) => { touchGesture = e.touches[0]?.clientY @@ -903,20 +824,14 @@ export function MessageTimeline(props: { const delta = prev - next 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, + const result = createTouchTimelineScrollIntent({ + root: e.currentTarget, + target: e.target, + delta, }) - markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) + if (!result) return + props.onTimelineScrollIntent(result.intent) + if (shouldMarkTimelineBoundaryGesture(result.boundary)) props.onMarkScrollGesture(e.currentTarget) }} onTouchEnd={() => { touchGesture = undefined From e548c22e620d7e648e1b2116e0ec027df626cc17 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 20:15:22 +0800 Subject: [PATCH 5/6] fix(app): clarify timeline scroll intent thresholds --- .../session/session-timeline-scroll-intents.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-scroll-intents.ts b/packages/app/src/pages/session/session-timeline-scroll-intents.ts index da4ce2f2c..ba94dbced 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-intents.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-intents.ts @@ -11,6 +11,11 @@ export type TimelineBoundaryGesture = { atNestedBoundary: boolean } +const scrollMetricThresholds = { + nearTop: 12, + nearBottom: 2, +} + export function timelineBoundaryTarget(root: HTMLElement, target: EventTarget | null) { const current = target instanceof Element ? target : undefined const nested = current?.closest("[data-scrollable]") @@ -20,7 +25,7 @@ export function timelineBoundaryTarget(root: HTMLElement, target: EventTarget | } export function timelineBoundaryGesture(input: { - root: HTMLDivElement + root: HTMLElement target: EventTarget | null delta: number }): TimelineBoundaryGesture { @@ -54,8 +59,8 @@ export function scrollViewMetricsToTimelineMetrics(metrics: { clientHeight: metrics.clientHeight, distanceFromTop: metrics.scrollTop, distanceFromBottom, - nearTop: metrics.scrollTop <= 12, - nearBottom: distanceFromBottom <= 2, + nearTop: metrics.scrollTop <= scrollMetricThresholds.nearTop, + nearBottom: distanceFromBottom <= scrollMetricThresholds.nearBottom, } } @@ -82,7 +87,7 @@ export type TimelineGestureIntentResult = { } export function createWheelTimelineScrollIntent(input: { - root: HTMLDivElement + root: HTMLElement target: EventTarget | null deltaY: number deltaMode: number @@ -103,7 +108,7 @@ export function createWheelTimelineScrollIntent(input: { } export function createTouchTimelineScrollIntent(input: { - root: HTMLDivElement + root: HTMLElement target: EventTarget | null delta: number }): TimelineGestureIntentResult | undefined { @@ -118,7 +123,7 @@ export function createTouchTimelineScrollIntent(input: { function createPointerTimelineScrollIntent(input: { type: "wheel_scroll" | "touch_scroll" - root: HTMLDivElement + root: HTMLElement target: EventTarget | null delta: number }): TimelineGestureIntentResult { From 79bfaf5eb032d97f909190be621e9f4e4922a04e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 20:19:58 +0800 Subject: [PATCH 6/6] fix(app): keep timeline staging behavior out of scroll intent slice --- .../session/session-timeline-staging.test.ts | 27 +++++++++++++++++++ .../pages/session/session-timeline-staging.ts | 8 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session/session-timeline-staging.test.ts b/packages/app/src/pages/session/session-timeline-staging.test.ts index 974c55193..969ea6caf 100644 --- a/packages/app/src/pages/session/session-timeline-staging.test.ts +++ b/packages/app/src/pages/session/session-timeline-staging.test.ts @@ -103,6 +103,33 @@ const mount = (factory) => { dispose() } +{ + const raf = installAnimationFrameQueue() + let staging + let setCount + const dispose = mount(() => { + const [count, nextCount] = createSignal(16) + setCount = nextCount + staging = createTimelineStaging({ + sessionKey: () => "ses_1", + turnStart: () => 6, + messages: () => messages(count()), + config: { init: 10, batch: 3 }, + }) + return null + }) + + const firstFrame = raf.pendingIDs()[0] + assert(firstFrame !== undefined, "active staging should schedule a frame") + setCount(18) + assert(ids(staging.messages()) === ids(messages(18).slice(8)), "active staging should not pop to all messages") + assert(staging.isStaging() === true, "message growth should keep staging active") + assert(raf.pendingIDs().includes(firstFrame), "message growth should keep the existing staging frame") + assert(raf.flushOne() === true, "existing staging frame should continue after growth") + assert(ids(staging.messages()) === ids(messages(18).slice(5)), "continued staging should add one batch after growth") + dispose() +} + { const raf = installAnimationFrameQueue() let staging diff --git a/packages/app/src/pages/session/session-timeline-staging.ts b/packages/app/src/pages/session/session-timeline-staging.ts index 2a27793b1..14abcb607 100644 --- a/packages/app/src/pages/session/session-timeline-staging.ts +++ b/packages/app/src/pages/session/session-timeline-staging.ts @@ -56,17 +56,19 @@ export function createTimelineStaging(input: TimelineStageInput) { on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { - cancel() const shouldStage = isWindowed && total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey + state.completedSession !== sessionKey if (!shouldStage) { + cancel() setState({ activeSession: "", count: total }) return } + if (state.activeSession === sessionKey) return + + cancel() let count = Math.min(total, input.config.init) setState({ activeSession: sessionKey, count })