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 - 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 + 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, + } +}