diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 5bca533e6a..423ccbf6c4 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -115,6 +115,46 @@ test.describe("smoke: session timeline", () => { .toBeLessThanOrEqual(1) }) + test("restores the persisted timeline position after reload", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, 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) + 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) + }) + test("paints cached session tabs at the latest message", async ({ page }) => { await mockOpenCodeServer(page, { sessions: fixture.sessions, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 898977e390..79cc6928b8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1076,6 +1076,14 @@ export default function Page() { working: () => true, overflowAnchor: "none", }) + const timelineScroll = () => view().scroll("timeline") + const hasTimelineScroll = createMemo(() => layout.ready() && !!timelineScroll()) + let timelineScrollSession = "" + const timelineScrollTop = () => { + const y = timelineScroll()?.y + if (y === Number.MAX_SAFE_INTEGER) return + return y + } createEffect( on( () => params.id, @@ -1119,6 +1127,15 @@ export default function Page() { }) } + const persistTimelineScroll = (el: HTMLDivElement) => { + if (!layout.ready() || timelineScrollSession !== sessionKey()) return + const max = el.scrollHeight - el.clientHeight + view().setScroll("timeline", { + x: max, + y: max <= 1 || max - el.scrollTop <= 2 ? Number.MAX_SAFE_INTEGER : el.scrollTop, + }) + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.resume() @@ -1516,6 +1533,53 @@ export default function Page() { }, ) + const restoreTimelineScroll = (saved: { x: number; y: number }) => { + const id = params.id + const owner = sessionOwnership.capture() + const key = sessionKey() + if (!id) return + + const apply = () => + owner.run(() => { + if (!scroller) 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 stable = Math.abs(scroller.scrollTop - top) < 1 + scroller.scrollTop = top + scheduleScrollState(scroller) + return stable + }) + 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() + } + apply() + let frames = 0 + let stable = 0 + const settle = () => { + if (!owner.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 +1602,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 +1810,7 @@ export default function Page() { onResumeScroll={resumeScroll} setScrollRef={setScrollRef} onScheduleScrollState={scheduleScrollState} + onPersistScroll={persistTimelineScroll} onAutoScrollHandleScroll={autoScroll.handleScroll} onMarkScrollGesture={markScrollGesture} hasScrollGesture={hasScrollGesture} @@ -1741,8 +1818,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..049359a0b2 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 + onPersistScroll: (el: HTMLDivElement) => 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) => { @@ -598,6 +600,7 @@ export function MessageTimeline(props: { 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() 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()