Selaa lähdekoodia

fix(app): restore timeline scroll position

Brendan Allan 1 kuukausi sitten
vanhempi
sitoutus
f7b287987b

+ 40 - 0
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,

+ 83 - 1
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<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

+ 4 - 1
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()

+ 17 - 0
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()