From 3783cda020e1d35d0e05e4600728a130ced5e44a Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 26 Jun 2026 01:53:36 +0800 Subject: [PATCH] fix(app): anchor restored timeline position --- .../app/e2e/smoke/session-timeline.fixture.ts | 9 ++- .../app/e2e/smoke/session-timeline.spec.ts | 20 +++++- .../app/src/context/layout-scroll.test.ts | 30 +++++++++ packages/app/src/context/layout-scroll.ts | 24 ++++++- packages/app/src/pages/session.tsx | 66 +++++++++++++++---- .../session/timeline/message-timeline.tsx | 12 +++- 6 files changed, 140 insertions(+), 21 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 3dce37cafd..a6b7d1ddb7 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -222,7 +222,10 @@ function turn(index: number): Message[] { return [user, assistantMessage(targetID, index, user.info.id, parts)] } -const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() +export const timelineMessages = (count: number, start = 0) => + Array.from({ length: count }, (_, index) => turn(start + index)).flat() + +const targetMessages = timelineMessages(72) const sourceMessages = Array.from({ length: 12 }, (_, index) => [ userMessage(sourceID, index + 1000, 120), assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), @@ -301,6 +304,10 @@ export const fixture = { export function pageMessages(sessionID: string, limit: number, before?: string) { const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] + return pageMessageList(messages, limit, before) +} + +export function pageMessageList(messages: Message[], limit: number, before?: string) { const end = before ? Math.max( 0, diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 423ccbf6c4..b89939c845 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" -import { fixture, pageMessages } from "./session-timeline.fixture" +import { fixture, pageMessageList, pageMessages, timelineMessages } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { mockOpenCodeServer } from "../utils/mock-server" import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits" @@ -116,12 +116,14 @@ test.describe("smoke: session timeline", () => { }) test("restores the persisted timeline position after reload", async ({ page }) => { + let messages = timelineMessages(140) await mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, - pageMessages, + pageMessages: (sessionID, limit, before) => + sessionID === fixture.targetID ? pageMessageList(messages, limit, before) : pageMessages(sessionID, limit, before), }) await configureSmokePage(page, fixture.directory) @@ -143,6 +145,9 @@ test.describe("smoke: session timeline", () => { (element) => element.scrollHeight - element.clientHeight - element.scrollTop, ), ).toBeGreaterThan(100) + const anchor = await firstVisibleMessage(page) + expect(anchor).toBeTruthy() + messages = [...messages, ...timelineMessages(120, 140)] await page.reload() await waitForTimelineStable(page) await expect.poll(() => timelineScroller(page).evaluate((element) => element.scrollTop)).toBeGreaterThan(100) @@ -153,6 +158,7 @@ test.describe("smoke: session timeline", () => { ), ) .toBeGreaterThan(100) + await expect.poll(() => firstVisibleMessage(page)).toBe(anchor) }) test("paints cached session tabs at the latest message", async ({ page }) => { @@ -597,6 +603,16 @@ function timelineScroller(page: Page) { return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) } +function firstVisibleMessage(page: Page) { + return timelineScroller(page).evaluate((element) => { + const box = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-message-id]")] + .map((message) => ({ id: message.dataset.messageId, rect: message.getBoundingClientRect() })) + .filter((message) => message.rect.bottom > box.top && message.rect.top < box.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0]?.id + }) +} + async function pointAtTimeline(page: Page) { const box = await timelineScroller(page).boundingBox() if (!box) throw new Error("Timeline scroller is not visible") diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index 483be150f6..2c91adbc3a 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -61,4 +61,34 @@ describe("createScrollPersistence", () => { expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 }) scroll.dispose() }) + + test("persists semantic scroll anchors", () => { + vi.useFakeTimers() + try { + let snapshot: Record = {} + const scroll = createScrollPersistence({ + debounceMs: 10, + getSnapshot: () => snapshot, + onFlush: (_sessionKey, next) => { + snapshot = next + }, + }) + + scroll.setScroll("session", "timeline", { + x: 1_000, + y: 400, + anchor: { id: "message-1", offset: 24 }, + }) + vi.advanceTimersByTime(10) + + expect(snapshot.timeline).toEqual({ + x: 1_000, + y: 400, + anchor: { id: "message-1", offset: 24 }, + }) + scroll.dispose() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts index ef66eccd90..3a1faeeb32 100644 --- a/packages/app/src/context/layout-scroll.ts +++ b/packages/app/src/context/layout-scroll.ts @@ -3,6 +3,10 @@ import { createStore, produce } from "solid-js/store" export type SessionScroll = { x: number y: number + anchor?: { + id: string + offset: number + } } type ScrollMap = Record @@ -26,7 +30,11 @@ export function createScrollPersistence(opts: Options) { for (const key of Object.keys(input)) { const pos = input[key] if (!pos) continue - out[key] = { x: pos.x, y: pos.y } + out[key] = { + x: pos.x, + y: pos.y, + anchor: pos.anchor ? { id: pos.anchor.id, offset: pos.anchor.offset } : undefined, + } } return out @@ -63,9 +71,19 @@ export function createScrollPersistence(opts: Options) { seed(sessionKey) const prev = cache[sessionKey]?.[tab] - if (prev?.x === pos.x && prev?.y === pos.y) return + if ( + prev?.x === pos.x && + prev?.y === pos.y && + prev?.anchor?.id === pos.anchor?.id && + prev?.anchor?.offset === pos.anchor?.offset + ) + return - setCache(sessionKey, tab, { x: pos.x, y: pos.y }) + setCache(sessionKey, tab, { + x: pos.x, + y: pos.y, + anchor: pos.anchor ? { id: pos.anchor.id, offset: pos.anchor.offset } : undefined, + }) dirty.add(sessionKey) schedule(sessionKey) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 79cc6928b8..e43f561af5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -35,6 +35,7 @@ import { useComments } from "@/context/comments" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import type { SessionScroll } from "@/context/layout-scroll" import { usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" @@ -1079,6 +1080,7 @@ export default function Page() { const timelineScroll = () => view().scroll("timeline") const hasTimelineScroll = createMemo(() => layout.ready() && !!timelineScroll()) let timelineScrollSession = "" + let timelineRestoreGeneration = 0 const timelineScrollTop = () => { const y = timelineScroll()?.y if (y === Number.MAX_SAFE_INTEGER) return @@ -1124,18 +1126,33 @@ export default function Page() { if (!target) return updateScrollState(target) + persistTimelineScroll(target) }) } const persistTimelineScroll = (el: HTMLDivElement) => { if (!layout.ready() || timelineScrollSession !== sessionKey()) return const max = el.scrollHeight - el.clientHeight + const box = el.getBoundingClientRect() + const anchor = [...el.querySelectorAll("[data-message-id]")] + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter((item) => item.rect.bottom > box.top && item.rect.top < box.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0] view().setScroll("timeline", { x: max, y: max <= 1 || max - el.scrollTop <= 2 ? Number.MAX_SAFE_INTEGER : el.scrollTop, + anchor: + max > 1 && max - el.scrollTop > 2 && anchor?.element.dataset.messageId + ? { id: anchor.element.dataset.messageId, offset: anchor.rect.top - box.top } + : undefined, }) } + const cancelTimelineScrollRestore = () => { + timelineRestoreGeneration += 1 + timelineScrollSession = sessionKey() + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.resume() @@ -1533,18 +1550,32 @@ export default function Page() { }, ) - const restoreTimelineScroll = (saved: { x: number; y: number }) => { + const restoreTimelineScroll = (saved: SessionScroll) => { const id = params.id const owner = sessionOwnership.capture() const key = sessionKey() + const generation = ++timelineRestoreGeneration if (!id) return + const current = () => owner.current() && timelineRestoreGeneration === generation + const apply = () => owner.run(() => { - if (!scroller) return + if (!scroller || !current()) return autoScroll.pause() const max = scroller.scrollHeight - scroller.clientHeight - const top = max < saved.y + 100 && !historyMore() && saved.x > 0 ? (saved.y / saved.x) * max : saved.y + const target = saved.anchor + ? scroller.querySelector(`[data-message-id="${CSS.escape(saved.anchor.id)}"]`) + : undefined + if (saved.anchor && !target) { + revealMessage(saved.anchor.id) + return false + } + const top = target + ? scroller.scrollTop + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - saved.anchor!.offset + : max < saved.y + 100 && !historyMore() && saved.x > 0 + ? (saved.y / saved.x) * max + : saved.y const stable = Math.abs(scroller.scrollTop - top) < 1 scroller.scrollTop = top scheduleScrollState(scroller) @@ -1553,20 +1584,29 @@ export default function Page() { apply() const load = async () => { - while (owner.current()) { - const el = scroller - if (!el || el.scrollHeight - el.clientHeight >= saved.y + 100 || !historyMore()) break - const before = timeline.messages().length - await sync().session.history.loadMore(id) - if (!owner.current() || timeline.messages().length <= before) break - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) - apply() + try { + while (current()) { + const found = !saved.anchor || visibleUserMessages().some((message) => message.id === saved.anchor?.id) + const tall = !!scroller && scroller.scrollHeight - scroller.clientHeight >= saved.y + 100 + if ((found && tall) || !historyMore()) break + const before = timeline.messages().length + await sync().session.history.loadMore(id) + if (!current() || timeline.messages().length <= before) break + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + apply() + } + } catch (error) { + if (current()) { + timelineScrollSession = key + console.error("[session] failed to restore timeline scroll", error) + } + return } apply() let frames = 0 let stable = 0 const settle = () => { - if (!owner.current()) return + if (!current()) return stable = apply() ? stable + 1 : 0 frames += 1 if (stable >= 10 || frames >= 180) { @@ -1810,7 +1850,7 @@ export default function Page() { onResumeScroll={resumeScroll} setScrollRef={setScrollRef} onScheduleScrollState={scheduleScrollState} - onPersistScroll={persistTimelineScroll} + onCancelScrollRestore={cancelTimelineScrollRestore} onAutoScrollHandleScroll={autoScroll.handleScroll} onMarkScrollGesture={markScrollGesture} hasScrollGesture={hasScrollGesture} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 049359a0b2..f58c12a871 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -235,7 +235,7 @@ export function MessageTimeline(props: { onResumeScroll: () => void setScrollRef: (el: HTMLDivElement | undefined) => void onScheduleScrollState: (el: HTMLDivElement) => void - onPersistScroll: (el: HTMLDivElement) => void + onCancelScrollRestore: () => void onAutoScrollHandleScroll: () => void onMarkScrollGesture: (target?: EventTarget | null) => void hasScrollGesture: () => boolean @@ -554,6 +554,7 @@ export function MessageTimeline(props: { } const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { + props.onCancelScrollRestore() if (!prependLoading) clearPrependAnchor() const root = event.currentTarget const delta = normalizeWheelDelta({ @@ -566,6 +567,7 @@ export function MessageTimeline(props: { } const handleListTouchStart = (event: TouchEvent) => { + props.onCancelScrollRestore() if (!prependLoading) clearPrependAnchor() touchGesture = event.touches[0]?.clientY } @@ -592,15 +594,20 @@ export function MessageTimeline(props: { } const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { + props.onCancelScrollRestore() if (!prependLoading) clearPrependAnchor() if (event.target !== event.currentTarget) return props.onMarkScrollGesture(event.currentTarget) } + const handleListKeyDown = (event: KeyboardEvent) => { + if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) return + props.onCancelScrollRestore() + } + const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { if (prependLoading) updatePrependAnchor() props.onScheduleScrollState(event.currentTarget) - props.onPersistScroll(event.currentTarget) props.onHistoryScroll() if (!props.hasScrollGesture()) return props.onUserScroll() @@ -1280,6 +1287,7 @@ export function MessageTimeline(props: { onTouchEnd={handleListTouchEnd} onTouchCancel={handleListTouchEnd} onPointerDown={handleListPointerDown} + onKeyDown={handleListKeyDown} onScroll={handleListScroll} onClick={props.onAutoScrollInteraction} class="relative min-w-0 w-full h-full"