fix(app): restore timeline scroll position

This commit is contained in:
Brendan Allan 2026-06-26 01:35:40 +08:00
commit f7b287987b
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
4 changed files with 144 additions and 2 deletions

View file

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

View file

@ -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<void>((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

View file

@ -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()

View file

@ -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()