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 5bca533e6a..19c5d773d0 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" @@ -115,6 +115,73 @@ test.describe("smoke: session timeline", () => { .toBeLessThanOrEqual(1) }) + test("restores the persisted timeline position across tabs and reload", async ({ page }) => { + let messages = timelineMessages(140) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID, limit, before) => + sessionID === fixture.targetID ? pageMessageList(messages, limit, before) : pageMessages(sessionID, limit, before), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + + await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + await pointAtTimeline(page) + await page.mouse.wheel(0, -1_000) + await expect + .poll(() => + timelineScroller(page).evaluate( + (element) => element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ) + .toBeGreaterThan(100) + await page.waitForTimeout(500) + expect(await timelineScroller(page).evaluate((element) => element.scrollTop)).toBeGreaterThan(100) + expect( + await timelineScroller(page).evaluate( + (element) => element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ).toBeGreaterThan(100) + const anchor = await firstVisibleTimelineRow(page) + expect(anchor?.id).toBeTruthy() + expect(anchor?.key).toBeTruthy() + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await expect.poll(() => firstVisibleTimelineRow(page)).toEqual(anchor) + messages = [...messages, ...timelineMessages(120, 140)] + await page.reload() + await waitForTimelineStable(page) + await expect.poll(() => timelineScroller(page).evaluate((element) => element.scrollTop)).toBeGreaterThan(100) + await expect + .poll(() => + timelineScroller(page).evaluate( + (element) => element.scrollHeight - element.clientHeight - element.scrollTop, + ), + ) + .toBeGreaterThan(100) + await expect.poll(() => firstVisibleTimelineRow(page)).toEqual(anchor) + }) + test("paints cached session tabs at the latest message", async ({ page }) => { await mockOpenCodeServer(page, { sessions: fixture.sessions, @@ -557,6 +624,23 @@ function timelineScroller(page: Page) { return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) } +function firstVisibleTimelineRow(page: Page) { + return timelineScroller(page).evaluate((element) => { + const box = element.getBoundingClientRect() + const row = [...element.querySelectorAll("[data-timeline-key]")] + .map((row) => ({ + id: row.querySelector("[data-message-id]")?.dataset.messageId, + key: row.dataset.timelineKey, + offset: Math.round(row.getBoundingClientRect().top - box.top), + rect: row.getBoundingClientRect(), + })) + .filter((row) => row.rect.bottom > box.top && row.rect.top < box.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0] + if (!row) return + return { id: row.id, key: row.key, offset: row.offset } + }) +} + 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..0e33b90673 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", key: "assistant-part:message-1:part-2", offset: 24 }, + }) + vi.advanceTimersByTime(10) + + expect(snapshot.timeline).toEqual({ + x: 1_000, + y: 400, + anchor: { id: "message-1", key: "assistant-part:message-1:part-2", 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..3f95704a43 100644 --- a/packages/app/src/context/layout-scroll.ts +++ b/packages/app/src/context/layout-scroll.ts @@ -3,6 +3,11 @@ import { createStore, produce } from "solid-js/store" export type SessionScroll = { x: number y: number + anchor?: { + id: string + key?: string + offset: number + } } type ScrollMap = Record @@ -26,7 +31,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, key: pos.anchor.key, offset: pos.anchor.offset } : undefined, + } } return out @@ -63,9 +72,20 @@ 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?.key === pos.anchor?.key && + 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, key: pos.anchor.key, 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 898977e390..e79b68adc8 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" @@ -1076,6 +1077,15 @@ export default function Page() { working: () => true, overflowAnchor: "none", }) + 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 + return y + } createEffect( on( () => params.id, @@ -1116,9 +1126,41 @@ 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-timeline-key]")] + .map((element) => ({ + element, + message: element.querySelector("[data-message-id]"), + 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?.message?.dataset.messageId && anchor.element.dataset.timelineKey + ? { + id: anchor.message.dataset.messageId, + key: anchor.element.dataset.timelineKey, + offset: anchor.rect.top - box.top, + } + : undefined, + }) + } + + const cancelTimelineScrollRestore = () => { + timelineRestoreGeneration += 1 + timelineScrollSession = sessionKey() + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.resume() @@ -1516,6 +1558,78 @@ export default function Page() { }, ) + 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 || !current()) return + autoScroll.pause() + const max = scroller.scrollHeight - scroller.clientHeight + const target = saved.anchor?.key + ? scroller.querySelector(`[data-timeline-key="${CSS.escape(saved.anchor.key)}"]`) + : 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) + return stable + }) + apply() + + const load = async () => { + 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 (!current()) return + stable = apply() ? stable + 1 : 0 + frames += 1 + if (stable >= 10 || frames >= 180) { + timelineScrollSession = key + return + } + requestAnimationFrame(settle) + } + requestAnimationFrame(settle) + } + void load() + } + const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ sessionKey, sessionID: () => params.id, @@ -1538,6 +1652,18 @@ export default function Page() { scroller: () => scroller, anchor, revealMessage: (id) => revealMessage(id), + scrollReady: layout.ready, + hasSavedScroll: hasTimelineScroll, + restoreScroll: () => { + const saved = timelineScroll() + const el = scroller + if (!saved || saved.y === Number.MAX_SAFE_INTEGER || !el) return false + restoreTimelineScroll(saved) + return true + }, + onApplyScroll: () => { + timelineScrollSession = sessionKey() + }, scheduleScrollState, consumePendingMessage: layout.pendingMessage.consume, }) @@ -1734,6 +1860,7 @@ export default function Page() { onResumeScroll={resumeScroll} setScrollRef={setScrollRef} onScheduleScrollState={scheduleScrollState} + onCancelScrollRestore={cancelTimelineScrollRestore} onAutoScrollHandleScroll={autoScroll.handleScroll} onMarkScrollGesture={markScrollGesture} hasScrollGesture={hasScrollGesture} @@ -1741,8 +1868,13 @@ export default function Page() { onHistoryScroll={onHistoryScroll} onAutoScrollInteraction={autoScroll.handleInteraction} shouldAnchorBottom={() => - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() + !location.hash && + !store.messageId && + !ui.pendingMessage && + !autoScroll.userScrolled() && + timelineScrollTop() === undefined } + initialScrollTop={timelineScrollTop} centered={centered()} setContentRef={(el) => { content = el diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 5d1bc9f864..f58c12a871 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -235,6 +235,7 @@ export function MessageTimeline(props: { onResumeScroll: () => void setScrollRef: (el: HTMLDivElement | undefined) => void onScheduleScrollState: (el: HTMLDivElement) => void + onCancelScrollRestore: () => void onAutoScrollHandleScroll: () => void onMarkScrollGesture: (target?: EventTarget | null) => void hasScrollGesture: () => boolean @@ -242,6 +243,7 @@ export function MessageTimeline(props: { onHistoryScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void shouldAnchorBottom: () => boolean + initialScrollTop: () => number | undefined centered: boolean setContentRef: (el: HTMLDivElement) => void userMessages: UserMessage[] @@ -400,7 +402,7 @@ export function MessageTimeline(props: { return timelineRows().length }, getScrollElement: () => listRoot() ?? null, - initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), + initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : (props.initialScrollTop() ?? 0)), initialMeasurementsCache: initialMeasurements, estimateSize: () => timelineFallbackItemSize, scrollToFn: (offset, options, instance) => { @@ -552,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({ @@ -564,6 +567,7 @@ export function MessageTimeline(props: { } const handleListTouchStart = (event: TouchEvent) => { + props.onCancelScrollRestore() if (!prependLoading) clearPrependAnchor() touchGesture = event.touches[0]?.clientY } @@ -590,11 +594,17 @@ 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) @@ -1277,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" 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 65577abae2..c44b63074e 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -19,12 +19,17 @@ export const useSessionHashScroll = (input: { scroller: () => HTMLDivElement | undefined anchor: (id: string) => string revealMessage?: (id: string) => void + scrollReady: () => boolean + hasSavedScroll: () => boolean + restoreScroll: () => boolean + onApplyScroll: () => void scheduleScrollState: (el: HTMLDivElement) => void consumePendingMessage: (key: string) => string | undefined }) => { const visibleUserMessages = createMemo(() => input.visibleUserMessages()) const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) let pendingKey = "" + let restoredKey = "" let clearing = false const location = useLocation() @@ -99,14 +104,24 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { + const key = input.sessionKey() + const initial = restoredKey !== key const hash = location.hash.slice(1) if (!hash) { + if (initial && input.restoreScroll()) { + restoredKey = key + return + } input.autoScroll.forceScrollToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) + if (input.scrollReady()) input.onApplyScroll() return } + restoredKey = key + input.onApplyScroll() + const messageId = messageIdFromHash(hash) if (messageId) { input.autoScroll.pause() @@ -132,6 +147,8 @@ export const useSessionHashScroll = (input: { createEffect(() => { const hash = location.hash + input.scrollReady() + input.hasSavedScroll() if (!hash) clearing = false if (!input.sessionID() || !input.messagesReady()) return cancel()