fix(tui): load paginated session history (#44656)

Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-24 16:02:19 +05:30 committed by GitHub
parent 97daae9b77
commit 3b8949b1ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 169 additions and 13 deletions

View file

@ -0,0 +1,31 @@
export function createHistoryPrepend(input: {
sessionID: () => string
more: (sessionID: string) => boolean
loadMore: (sessionID: string) => Promise<void>
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
}
}

View file

@ -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()
},
},

View file

@ -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<void>((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<void>((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"])
})