From 3b8949b1ee2552835054ba418568d4a62e2fd830 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:19 +0530 Subject: [PATCH] fix(tui): load paginated session history (#44656) Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com> --- packages/tui/src/routes/session/history.ts | 31 ++++++ packages/tui/src/routes/session/index.tsx | 48 +++++--- .../tui/test/cli/tui/session-history.test.ts | 103 ++++++++++++++++++ 3 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 packages/tui/src/routes/session/history.ts create mode 100644 packages/tui/test/cli/tui/session-history.test.ts diff --git a/packages/tui/src/routes/session/history.ts b/packages/tui/src/routes/session/history.ts new file mode 100644 index 00000000000..9de53b07972 --- /dev/null +++ b/packages/tui/src/routes/session/history.ts @@ -0,0 +1,31 @@ +export function createHistoryPrepend(input: { + sessionID: () => string + more: (sessionID: string) => boolean + loadMore: (sessionID: string) => Promise + height: () => number + afterLayout: (continuation: () => void) => void + active: (sessionID: string) => boolean + scrollBy: (amount: number) => void +}) { + let loading = false + + return (scrollBy = 0, continuation?: () => void) => { + const sessionID = input.sessionID() + if (loading || !input.more(sessionID)) return false + loading = true + const before = input.height() + void input.loadMore(sessionID).then( + () => + input.afterLayout(() => { + loading = false + if (!input.active(sessionID)) return + input.scrollBy(input.height() - before + scrollBy) + continuation?.() + }), + () => { + loading = false + }, + ) + return true + } +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c82c7185470..4edc4c53fa0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -109,6 +109,7 @@ import { generateThinkingSyntax } from "./thinking-syntax" import { createDelayedPresence } from "../../util/delayed-presence" import { SessionLocationMissing } from "./location-missing" import { isRecord } from "../../util/record" +import { createHistoryPrepend } from "./history" addDefaultParsers(parsers.parsers) @@ -383,17 +384,23 @@ export function Session(props: { verticalTabsWidth: number }) { const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS))) const visibleEnd = createMemo(() => Math.max(hidden(), Math.min(visibleRowsEnd() ?? rows.length, rows.length))) const visibleRows = createMemo(() => rows.slice(hidden(), visibleEnd())) + const prependHistory = createHistoryPrepend({ + sessionID: () => route.sessionID, + more: (id) => data.session.message.more(id), + loadMore: (id) => data.session.message.loadMore(id), + height: () => scroll.scrollHeight, + afterLayout, + active: (id) => route.sessionID === id && Boolean(scroll && !scroll.isDestroyed), + scrollBy: (amount) => { + scroll.scrollBy(amount) + updateAwayFromBottom() + }, + }) let revealingOlderRows = false const revealOlderRows = (scrollBy = 0) => { const current = hidden() - if ( - revealingOlderRows || - current === 0 || - !scroll || - scroll.isDestroyed || - scroll.scrollTop > scroll.viewport.height - ) - return false + if (revealingOlderRows || !scroll || scroll.isDestroyed || scroll.scrollTop > scroll.viewport.height) return false + if (current === 0) return prependHistory(scrollBy) revealingOlderRows = true const before = scroll.scrollHeight setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK)) @@ -594,7 +601,15 @@ export function Session(props: { verticalTabsWidth: number }) { userOnly, }) - if (target) alignMessage(target.id, target.top) + if (target) { + alignMessage(target.id, target.top) + dialog.clear() + return + } + if (direction === "prev" && data.session.message.more(route.sessionID)) { + prependHistory(0, () => scrollToMessage(direction, dialog, userOnly)) + return + } dialog.clear() }) @@ -690,10 +705,17 @@ export function Session(props: { verticalTabsWidth: number }) { palette: undefined, run: () => { clearMessageNavigation() - ensureAllRows(() => { - scroll.scrollTo(0) - updateAwayFromBottom() - }) + const first = () => { + if (data.session.message.more(route.sessionID)) { + prependHistory(0, first) + return + } + ensureAllRows(() => { + scroll.scrollTo(0) + updateAwayFromBottom() + }) + } + first() dialog.clear() }, }, diff --git a/packages/tui/test/cli/tui/session-history.test.ts b/packages/tui/test/cli/tui/session-history.test.ts new file mode 100644 index 00000000000..a61fd47f1e3 --- /dev/null +++ b/packages/tui/test/cli/tui/session-history.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test" +import { createHistoryPrepend } from "../../../src/routes/session/history" + +test("loads older history and preserves the visible scroll anchor", async () => { + let height = 100 + let resolveLoad: (() => void) | undefined + const scrolled: number[] = [] + const prepend = createHistoryPrepend({ + sessionID: () => "session-1", + more: () => true, + loadMore: () => + new Promise((resolve) => { + resolveLoad = () => { + height = 160 + resolve() + } + }), + height: () => height, + afterLayout: (continuation) => continuation(), + active: (sessionID) => sessionID === "session-1", + scrollBy: (amount) => scrolled.push(amount), + }) + + expect(prepend(-4)).toBe(true) + expect(prepend(-4)).toBe(false) + resolveLoad?.() + await Promise.resolve() + await Promise.resolve() + + expect(scrolled).toEqual([56]) +}) + +test("releases the history load after a failed request", async () => { + let attempts = 0 + const prepend = createHistoryPrepend({ + sessionID: () => "session-1", + more: () => true, + loadMore: () => { + attempts++ + return Promise.reject(new Error("offline")) + }, + height: () => 100, + afterLayout: (continuation) => continuation(), + active: () => true, + scrollBy: () => undefined, + }) + + expect(prepend()).toBe(true) + await Promise.resolve() + await Promise.resolve() + expect(prepend()).toBe(true) + expect(attempts).toBe(2) +}) + +test("does not move a different session after history loads", async () => { + let current = "session-1" + let resolveLoad: (() => void) | undefined + const scrolled: number[] = [] + const prepend = createHistoryPrepend({ + sessionID: () => current, + more: () => true, + loadMore: () => + new Promise((resolve) => { + resolveLoad = resolve + }), + height: () => 160, + afterLayout: (continuation) => continuation(), + active: (sessionID) => current === sessionID, + scrollBy: (amount) => scrolled.push(amount), + }) + + expect(prepend()).toBe(true) + current = "session-2" + resolveLoad?.() + await Promise.resolve() + await Promise.resolve() + + expect(scrolled).toEqual([]) +}) + +test("continues navigation after the prepended page is laid out", async () => { + const events: string[] = [] + const prepend = createHistoryPrepend({ + sessionID: () => "session-1", + more: () => true, + loadMore: async () => { + events.push("loaded") + }, + height: () => 100, + afterLayout: (continuation) => { + events.push("layout") + continuation() + }, + active: () => true, + scrollBy: () => events.push("anchored"), + }) + + expect(prepend(0, () => events.push("continued"))).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(events).toEqual(["loaded", "layout", "anchored", "continued"]) +})