fix(app): restore timeline scroll position
This commit is contained in:
parent
a267222080
commit
f7b287987b
4 changed files with 144 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue