mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 08:32:01 +00:00
fix(tui): load paginated session history (#44656)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
This commit is contained in:
parent
97daae9b77
commit
3b8949b1ee
3 changed files with 169 additions and 13 deletions
31
packages/tui/src/routes/session/history.ts
Normal file
31
packages/tui/src/routes/session/history.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
103
packages/tui/test/cli/tui/session-history.test.ts
Normal file
103
packages/tui/test/cli/tui/session-history.test.ts
Normal 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"])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue