fix(tui): load history until the oldest group is complete (#50930)

This commit is contained in:
James Long
2026-09-23 11:58:44 -04:00
committed by GitHub
parent bb7876dfa8
commit 0332a26be6
4 changed files with 183 additions and 7 deletions
+11 -1
View File
@@ -90,6 +90,7 @@ import { Slot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import {
cacheReuseDrop,
completeGroupBoundary,
createSessionRows,
legacyTurns,
messageBoundaryIDs,
@@ -395,7 +396,16 @@ export function Session(props: {
const prependHistory = createHistoryPrepend({
sessionID: () => route.sessionID,
more: (id) => data.session.message.more(id),
loadMore: (id) => data.session.message.loadMore(id),
loadMore: async (id) => {
await data.session.message.loadMore(id)
await completeGroupBoundary({
rows,
messages: () => data.session.message.list(id).length,
more: () => data.session.message.more(id),
loadMore: () => data.session.message.loadMore(id),
active: () => route.sessionID === id,
})
},
height: () => scroll.scrollHeight,
afterLayout,
active: (id) => route.sessionID === id && Boolean(scroll && !scroll.isDestroyed),
+34 -6
View File
@@ -19,6 +19,26 @@ import {
} from "./grouping/session"
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
/**
* A page boundary can cut a group in half, which would show a partial summary and
* give the group a provisional ID (derived from its first part). While the oldest
* row is a group, keep loading older pages until something precedes it.
*/
export async function completeGroupBoundary(input: {
rows: readonly SessionRow[]
messages: () => number
more: () => boolean
loadMore: () => Promise<void>
active: () => boolean
}) {
while (input.active() && input.rows[0]?.type === "group" && input.more()) {
const before = input.messages()
await input.loadMore()
// A page that adds nothing would otherwise loop forever.
if (input.messages() === before) return
}
}
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
const data = useData()
const client = useClient()
@@ -75,14 +95,22 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
if (status !== "connected") return
setRows(reconcile(reduce()))
void data.session.pending.sync(id).catch(() => undefined)
void data.session.message.sync(id).then(
() => {
void data.session.message
.sync(id)
.then(async () => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
onSynced?.(id)
},
() => undefined,
)
// Restoration waits for complete boundary groups so saved group IDs resolve.
await completeGroupBoundary({
rows,
messages: () => data.session.message.list(id).length,
more: () => data.session.message.more(id),
loadMore: () => data.session.message.loadMore(id),
active: () => sessionID() === id,
}).catch(() => undefined)
if (sessionID() === id) onSynced?.(id)
})
.catch(() => undefined)
}),
)
+75
View File
@@ -18,6 +18,8 @@ import { Composer } from "../../../src/routes/session/composer"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { groupRefs } from "../../../src/routes/session/grouping/session"
import { unwrap } from "solid-js/store"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -1085,6 +1087,79 @@ test("classifies live tool rows independently of their call ID", async () => {
}
})
test("loads older pages until the oldest exploration group is complete before reporting sync", async () => {
const events = createEventStream()
const sessionID = "session-boundary"
const model = { id: "model", providerID: "provider" }
// One prompt, then 50 single-read steps: the 20-message first page cuts the group.
const history = [
{ type: "user", id: "msg_000", text: "Explore", time: { created: 0 } },
...Array.from({ length: 50 }, (_, index) => ({
type: "assistant",
id: `msg_${String(index + 1).padStart(3, "0")}`,
agent: "build",
model,
time: { created: index + 1, completed: index + 1 },
finish: "tool-calls",
content: [
{
type: "tool",
id: `read-${index}`,
name: "read",
time: { created: index + 1, completed: index + 1 },
state: { status: "completed", input: { path: `${index}.ts` }, content: [], metadata: {} },
},
],
})),
]
const pages: string[] = []
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/message`) return
const end = Number(url.searchParams.get("cursor") ?? history.length)
const start = Math.max(0, end - Number(url.searchParams.get("limit") ?? 20))
pages.push(`${start}-${end}`)
return json({ data: history.slice(start, end).toReversed(), cursor: start > 0 ? { next: String(start) } : {} })
}, events)
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
const synced: SessionRow[] = []
function Probe() {
client = useClient()
rows = createSessionRows(
() => sessionID,
() => synced.push(structuredClone(unwrap(rows[0]))),
)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
await wait(() => synced.length > 0, 4000)
expect(pages).toEqual(["31-51", "11-31", "0-11"])
// Sync is reported only once the group's true first read is loaded.
expect(synced[0]).toEqual({ type: "message", messageID: "msg_000" })
const group = rows[1]
if (group?.type !== "group") throw new Error("Expected exploration group")
expect(group.size).toBe(50)
expect(groupRefs(group)[0]).toEqual({ messageID: "msg_001", partID: "read-0" })
} finally {
app.renderer.destroy()
}
})
test("removes committed revert messages from local state", async () => {
const events = createEventStream()
const sessionID = "session-revert"
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test"
import { completeGroupBoundary, type SessionRow } from "../../../src/routes/session/rows"
const group: SessionRow = { type: "group", kind: "reasoning", children: [], size: 0, completed: true }
const prompt: SessionRow = { type: "message", messageID: "msg_user" }
// Each page is the number of older messages it adds and whether it reaches the prompt
// preceding the group. Pages inside the group merge into the existing first row.
function history(pages: { messages: number; prompt?: boolean }[], first: SessionRow = group) {
const rows: SessionRow[] = [first]
let count = 1
let loads = 0
let active = true
return {
rows,
loads: () => loads,
deactivate: () => (active = false),
input: (after?: () => void) => ({
rows,
messages: () => count,
more: () => loads < pages.length,
loadMore: async () => {
const page = pages[loads++]
count += page.messages
if (page.prompt) rows.unshift(prompt)
after?.()
},
active: () => active,
}),
}
}
test("keeps loading while the oldest row is a group", async () => {
const state = history([{ messages: 20 }, { messages: 20 }, { messages: 5, prompt: true }, { messages: 20 }])
await completeGroupBoundary(state.input())
expect(state.loads()).toBe(3)
expect(state.rows[0]).toBe(prompt)
})
test("does not load when the oldest row cannot continue into older history", async () => {
const state = history([{ messages: 20 }], prompt)
await completeGroupBoundary(state.input())
expect(state.loads()).toBe(0)
})
test("stops at the beginning of history", async () => {
const state = history([{ messages: 20 }, { messages: 3 }])
await completeGroupBoundary(state.input())
expect(state.loads()).toBe(2)
expect(state.rows[0]).toBe(group)
})
test("stops when a page adds no messages", async () => {
const state = history([{ messages: 0 }, { messages: 20 }])
await completeGroupBoundary(state.input())
expect(state.loads()).toBe(1)
})
test("stops when the session is no longer active", async () => {
const state = history([{ messages: 20 }, { messages: 20 }, { messages: 1, prompt: true }])
await completeGroupBoundary(state.input(() => state.deactivate()))
expect(state.loads()).toBe(1)
})