Procházet zdrojové kódy

fix(app): messages not loading reliably

Adam před 5 měsíci
rodič
revize
c797b60069

+ 47 - 206
packages/app/src/pages/session.tsx

@@ -41,216 +41,12 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
+import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { same } from "@/utils/same"
 import { formatServerError } from "@/utils/server-errors"
 
-const emptyUserMessages: UserMessage[] = []
-
-type SessionHistoryWindowInput = {
-  sessionID: () => string | undefined
-  messagesReady: () => boolean
-  visibleUserMessages: () => UserMessage[]
-  historyMore: () => boolean
-  historyLoading: () => boolean
-  loadMore: (sessionID: string) => Promise<void>
-  userScrolled: () => boolean
-  scroller: () => HTMLDivElement | undefined
-}
-
-/**
- * Maintains the rendered history window for a session timeline.
- *
- * It keeps initial paint bounded to recent turns, reveals cached turns in
- * small batches while scrolling upward, and prefetches older history near top.
- */
-function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
-  const turnInit = 10
-  const turnBatch = 8
-  const turnScrollThreshold = 200
-  const turnPrefetchBuffer = 16
-  const prefetchCooldownMs = 400
-  const prefetchNoGrowthLimit = 2
-
-  const [state, setState] = createStore({
-    turnID: undefined as string | undefined,
-    turnStart: 0,
-    prefetchUntil: 0,
-    prefetchNoGrowth: 0,
-  })
-
-  const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
-
-  const turnStart = createMemo(() => {
-    const id = input.sessionID()
-    const len = input.visibleUserMessages().length
-    if (!id || len <= 0) return 0
-    if (state.turnID !== id) return initialTurnStart(len)
-    if (state.turnStart <= 0) return 0
-    if (state.turnStart >= len) return initialTurnStart(len)
-    return state.turnStart
-  })
-
-  const setTurnStart = (start: number) => {
-    const id = input.sessionID()
-    const next = start > 0 ? start : 0
-    if (!id) {
-      setState({ turnID: undefined, turnStart: next })
-      return
-    }
-    setState({ turnID: id, turnStart: next })
-  }
-
-  const renderedUserMessages = createMemo(
-    () => {
-      const msgs = input.visibleUserMessages()
-      const start = turnStart()
-      if (start <= 0) return msgs
-      return msgs.slice(start)
-    },
-    emptyUserMessages,
-    {
-      equals: same,
-    },
-  )
-
-  const preserveScroll = (fn: () => void) => {
-    const el = input.scroller()
-    if (!el) {
-      fn()
-      return
-    }
-    const beforeTop = el.scrollTop
-    fn()
-    void el.scrollHeight
-    el.scrollTop = beforeTop
-  }
-
-  const backfillTurns = () => {
-    const start = turnStart()
-    if (start <= 0) return
-
-    const next = start - turnBatch
-    const nextStart = next > 0 ? next : 0
-
-    preserveScroll(() => setTurnStart(nextStart))
-  }
-
-  /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
-  const loadAndReveal = async () => {
-    const id = input.sessionID()
-    if (!id) return
-
-    const start = turnStart()
-    const beforeVisible = input.visibleUserMessages().length
-
-    if (start > 0) setTurnStart(0)
-
-    if (!input.historyMore() || input.historyLoading()) return
-
-    await input.loadMore(id)
-    if (input.sessionID() !== id) return
-
-    const afterVisible = input.visibleUserMessages().length
-    const growth = afterVisible - beforeVisible
-    if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
-    if (growth <= 0) return
-    if (turnStart() !== 0) return
-
-    const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
-    const nextStart = Math.max(0, afterVisible - target)
-    preserveScroll(() => setTurnStart(nextStart))
-  }
-
-  /** Scroll/prefetch path: fetch older history from server. */
-  const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
-    const id = input.sessionID()
-    if (!id) return
-    if (!input.historyMore() || input.historyLoading()) return
-
-    if (opts?.prefetch) {
-      const now = Date.now()
-      if (state.prefetchUntil > now) return
-      if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
-      setState("prefetchUntil", now + prefetchCooldownMs)
-    }
-
-    const start = turnStart()
-    const beforeVisible = input.visibleUserMessages().length
-    const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
-
-    await input.loadMore(id)
-    if (input.sessionID() !== id) return
-
-    const afterVisible = input.visibleUserMessages().length
-    const growth = afterVisible - beforeVisible
-
-    if (opts?.prefetch) {
-      setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
-    } else if (growth > 0 && state.prefetchNoGrowth) {
-      setState("prefetchNoGrowth", 0)
-    }
-
-    if (growth <= 0) return
-    if (turnStart() !== start) return
-
-    const reveal = !opts?.prefetch
-    const currentRendered = renderedUserMessages().length
-    const base = Math.max(beforeRendered, currentRendered)
-    const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
-    const nextStart = Math.max(0, afterVisible - target)
-    preserveScroll(() => setTurnStart(nextStart))
-  }
-
-  const onScrollerScroll = () => {
-    if (!input.userScrolled()) return
-    const el = input.scroller()
-    if (!el) return
-    if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
-
-    const start = turnStart()
-    if (start > 0) {
-      if (start <= turnPrefetchBuffer) {
-        void fetchOlderMessages({ prefetch: true })
-      }
-      backfillTurns()
-      return
-    }
-
-    void fetchOlderMessages()
-  }
-
-  createEffect(
-    on(
-      input.sessionID,
-      () => {
-        setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
-      },
-      { defer: true },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => [input.sessionID(), input.messagesReady()] as const,
-      ([id, ready]) => {
-        if (!id || !ready) return
-        setTurnStart(initialTurnStart(input.visibleUserMessages().length))
-      },
-      { defer: true },
-    ),
-  )
-
-  return {
-    turnStart,
-    setTurnStart,
-    renderedUserMessages,
-    loadAndReveal,
-    onScrollerScroll,
-  }
-}
-
 export default function Page() {
   const globalSync = useGlobalSync()
   const layout = useLayout()
@@ -1090,6 +886,7 @@ export default function Page() {
 
   let scrollStateFrame: number | undefined
   let scrollStateTarget: HTMLDivElement | undefined
+  let historyFillFrame: number | undefined
   const scrollSpy = createScrollSpy({
     onActive: (id) => {
       if (id === store.messageId) return
@@ -1159,7 +956,9 @@ export default function Page() {
     scroller = el
     autoScroll.scrollRef(el)
     scrollSpy.setContainer(el)
-    if (el) scheduleScrollState(el)
+    if (!el) return
+    scheduleScrollState(el)
+    scheduleHistoryFill()
   }
 
   createResizeObserver(
@@ -1168,6 +967,7 @@ export default function Page() {
       const el = scroller
       if (el) scheduleScrollState(el)
       scrollSpy.markDirty()
+      scheduleHistoryFill()
     },
   )
 
@@ -1182,6 +982,45 @@ export default function Page() {
     scroller: () => scroller,
   })
 
+  const scheduleHistoryFill = () => {
+    if (historyFillFrame !== undefined) return
+
+    historyFillFrame = requestAnimationFrame(() => {
+      historyFillFrame = undefined
+
+      if (!params.id || !messagesReady()) return
+      if (autoScroll.userScrolled() || historyLoading()) return
+
+      const el = scroller
+      if (!el) return
+      if (el.scrollHeight > el.clientHeight + 1) return
+      if (historyWindow.turnStart() <= 0 && !historyMore()) return
+
+      void historyWindow.loadAndReveal()
+    })
+  }
+
+  createEffect(
+    on(
+      () =>
+        [
+          params.id,
+          messagesReady(),
+          historyWindow.turnStart(),
+          historyMore(),
+          historyLoading(),
+          autoScroll.userScrolled(),
+          visibleUserMessages().length,
+        ] as const,
+      ([id, ready, start, more, loading, scrolled]) => {
+        if (!id || !ready || loading || scrolled) return
+        if (start <= 0 && !more) return
+        scheduleHistoryFill()
+      },
+      { defer: true },
+    ),
+  )
+
   createResizeObserver(
     () => promptDock,
     ({ height }) => {
@@ -1199,6 +1038,7 @@ export default function Page() {
 
       if (el) scheduleScrollState(el)
       scrollSpy.markDirty()
+      scheduleHistoryFill()
     },
   )
 
@@ -1228,6 +1068,7 @@ export default function Page() {
     document.removeEventListener("keydown", handleKeyDown)
     scrollSpy.destroy()
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
+    if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
   })
 
   return (

+ 35 - 0
packages/app/src/pages/session/history-window.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { historyLoadMode, historyRevealTop } from "./history-window"
+
+describe("historyLoadMode", () => {
+  test("reveals cached turns before fetching", () => {
+    expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
+  })
+
+  test("fetches older history when cache is already revealed", () => {
+    expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
+  })
+
+  test("does nothing while history is unavailable or loading", () => {
+    expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
+    expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
+  })
+})
+
+describe("historyRevealTop", () => {
+  test("pins the viewport to the top when older turns were revealed there", () => {
+    expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
+      -1400,
+    )
+  })
+
+  test("keeps the latest turns pinned when the viewport was underfilled", () => {
+    expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
+  })
+
+  test("keeps the current anchor when the user was not at the top", () => {
+    expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
+      -200,
+    )
+  })
+})

+ 273 - 0
packages/app/src/pages/session/history-window.ts

@@ -0,0 +1,273 @@
+import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { createEffect, createMemo, on } from "solid-js"
+import { createStore } from "solid-js/store"
+import { same } from "@/utils/same"
+
+export const emptyUserMessages: UserMessage[] = []
+
+export type SessionHistoryWindowInput = {
+  sessionID: () => string | undefined
+  messagesReady: () => boolean
+  visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
+  userScrolled: () => boolean
+  scroller: () => HTMLDivElement | undefined
+}
+
+type Snap = {
+  top: number
+  height: number
+  gap: number
+  max: number
+}
+
+export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
+  if (input.start > 0) return "reveal"
+  if (!input.more || input.loading) return "noop"
+  return "fetch"
+}
+
+export const historyRevealTop = (
+  mark: { top: number; height: number; gap: number; max: number },
+  next: { clientHeight: number; height: number },
+  threshold = 16,
+) => {
+  const delta = next.height - mark.height
+  if (delta <= 0) return mark.top
+  if (mark.max <= 0) return mark.top
+  if (mark.gap > threshold) return mark.top
+
+  const max = next.height - next.clientHeight
+  if (max <= 0) return 0
+  return Math.max(-max, Math.min(0, mark.top - delta))
+}
+
+const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
+  if (!el) return
+  const max = el.scrollHeight - el.clientHeight
+  return {
+    top: el.scrollTop,
+    height: el.scrollHeight,
+    gap: max + el.scrollTop,
+    max,
+  }
+}
+
+const clamp = (el: HTMLDivElement, top: number) => {
+  const max = el.scrollHeight - el.clientHeight
+  if (max <= 0) return 0
+  return Math.max(-max, Math.min(0, top))
+}
+
+const revealThreshold = 16
+
+const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
+  const el = input.scroller()
+  if (!el || !mark) return
+  el.scrollTop = clamp(
+    el,
+    historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
+  )
+}
+
+const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
+  const el = input.scroller()
+  if (!el) {
+    fn()
+    return
+  }
+  const top = el.scrollTop
+  fn()
+  el.scrollTop = top
+}
+
+/**
+ * Maintains the rendered history window for a session timeline.
+ *
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
+ * small batches while scrolling upward, and prefetches older history near top.
+ */
+export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+  const turnInit = 10
+  const turnBatch = 8
+  const turnScrollThreshold = 200
+  const turnPrefetchBuffer = 16
+  const prefetchCooldownMs = 400
+  const prefetchNoGrowthLimit = 2
+
+  const [state, setState] = createStore({
+    turnID: undefined as string | undefined,
+    turnStart: 0,
+    prefetchUntil: 0,
+    prefetchNoGrowth: 0,
+  })
+
+  const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
+
+  const turnStart = createMemo(() => {
+    const id = input.sessionID()
+    const len = input.visibleUserMessages().length
+    if (!id || len <= 0) return 0
+    if (state.turnID !== id) return initialTurnStart(len)
+    if (state.turnStart <= 0) return 0
+    if (state.turnStart >= len) return initialTurnStart(len)
+    return state.turnStart
+  })
+
+  const setTurnStart = (start: number) => {
+    const id = input.sessionID()
+    const next = start > 0 ? start : 0
+    if (!id) {
+      setState({ turnID: undefined, turnStart: next })
+      return
+    }
+    setState({ turnID: id, turnStart: next })
+  }
+
+  const renderedUserMessages = createMemo(
+    () => {
+      const msgs = input.visibleUserMessages()
+      const start = turnStart()
+      if (start <= 0) return msgs
+      return msgs.slice(start)
+    },
+    emptyUserMessages,
+    {
+      equals: same,
+    },
+  )
+
+  const backfillTurns = () => {
+    const start = turnStart()
+    if (start <= 0) return
+
+    const next = start - turnBatch
+    const nextStart = next > 0 ? next : 0
+
+    preserve(input, () => setTurnStart(nextStart))
+  }
+
+  /** Button path: reveal cached turns first, then fetch older history. */
+  const loadAndReveal = async () => {
+    const id = input.sessionID()
+    if (!id) return
+
+    const start = turnStart()
+    const mode = historyLoadMode({
+      start,
+      more: input.historyMore(),
+      loading: input.historyLoading(),
+    })
+
+    if (mode === "reveal") {
+      const mark = snap(input.scroller())
+      setTurnStart(0)
+      reveal(input, mark)
+      return
+    }
+
+    if (mode === "noop") return
+
+    const beforeVisible = input.visibleUserMessages().length
+    const mark = snap(input.scroller())
+
+    await input.loadMore(id)
+    if (input.sessionID() !== id) return
+
+    const afterVisible = input.visibleUserMessages().length
+    const growth = afterVisible - beforeVisible
+    if (growth <= 0) return
+    if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
+
+    reveal(input, mark)
+  }
+
+  /** Scroll/prefetch path: fetch older history from server. */
+  const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+    const id = input.sessionID()
+    if (!id) return
+    if (!input.historyMore() || input.historyLoading()) return
+
+    if (opts?.prefetch) {
+      const now = Date.now()
+      if (state.prefetchUntil > now) return
+      if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
+      setState("prefetchUntil", now + prefetchCooldownMs)
+    }
+
+    const start = turnStart()
+    const beforeVisible = input.visibleUserMessages().length
+    const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
+
+    await input.loadMore(id)
+    if (input.sessionID() !== id) return
+
+    const afterVisible = input.visibleUserMessages().length
+    const growth = afterVisible - beforeVisible
+
+    if (opts?.prefetch) {
+      setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
+    } else if (growth > 0 && state.prefetchNoGrowth) {
+      setState("prefetchNoGrowth", 0)
+    }
+
+    if (growth <= 0) return
+    if (turnStart() !== start) return
+
+    const revealMore = !opts?.prefetch
+    const currentRendered = renderedUserMessages().length
+    const base = Math.max(beforeRendered, currentRendered)
+    const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
+    const nextStart = Math.max(0, afterVisible - target)
+    preserve(input, () => setTurnStart(nextStart))
+  }
+
+  const onScrollerScroll = () => {
+    if (!input.userScrolled()) return
+    const el = input.scroller()
+    if (!el) return
+    if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
+
+    const start = turnStart()
+    if (start > 0) {
+      if (start <= turnPrefetchBuffer) {
+        void fetchOlderMessages({ prefetch: true })
+      }
+      backfillTurns()
+      return
+    }
+
+    void fetchOlderMessages()
+  }
+
+  createEffect(
+    on(
+      input.sessionID,
+      () => {
+        setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => [input.sessionID(), input.messagesReady()] as const,
+      ([id, ready]) => {
+        if (!id || !ready) return
+        setTurnStart(initialTurnStart(input.visibleUserMessages().length))
+      },
+      { defer: true },
+    ),
+  )
+
+  return {
+    turnStart,
+    setTurnStart,
+    renderedUserMessages,
+    loadAndReveal,
+    onScrollerScroll,
+  }
+}