fix(app): anchor restored timeline position

This commit is contained in:
Brendan Allan 2026-06-26 01:53:36 +08:00
commit 3783cda020
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
6 changed files with 140 additions and 21 deletions

View file

@ -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,

View file

@ -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<HTMLElement>("[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")

View file

@ -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<string, { x: number; y: number; anchor?: { id: string; offset: number } }> = {}
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()
}
})
})

View file

@ -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<string, SessionScroll>
@ -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)
}

View file

@ -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<HTMLElement>("[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<HTMLElement>(`[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<void>((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<void>((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}

View file

@ -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"