summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-08 06:11:57 -0500
committerAdam <[email protected]>2026-03-08 07:11:15 -0500
commitc797b60069df9b6510442e6e2d582c572f88d5c1 (patch)
tree524ad3a02bf17d8949850143be0a3037e5fd1ba5
parenta139e9297d2a269308c66efbc7ed2b7a53a59a16 (diff)
downloadopencode-c797b60069df9b6510442e6e2d582c572f88d5c1.tar.gz
opencode-c797b60069df9b6510442e6e2d582c572f88d5c1.zip
fix(app): messages not loading reliably
-rw-r--r--packages/app/src/pages/session.tsx253
-rw-r--r--packages/app/src/pages/session/history-window.test.ts35
-rw-r--r--packages/app/src/pages/session/history-window.ts273
3 files changed, 355 insertions, 206 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 4967eaa55..90769a28a 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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 (
diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts
new file mode 100644
index 000000000..4a9b894e2
--- /dev/null
+++ b/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,
+ )
+ })
+})
diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts
new file mode 100644
index 000000000..e3ef20f13
--- /dev/null
+++ b/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,
+ }
+}