From 0332a26be6847f5327b98f3afd519f683c4ce893 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 23 Sep 2026 11:58:44 -0400 Subject: [PATCH] fix(tui): load history until the oldest group is complete (#50930) --- packages/tui/src/routes/session/index.tsx | 12 ++- packages/tui/src/routes/session/rows.ts | 40 ++++++++-- packages/tui/test/cli/tui/data.test.tsx | 75 +++++++++++++++++++ .../tui/test/cli/tui/group-boundary.test.ts | 63 ++++++++++++++++ 4 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 packages/tui/test/cli/tui/group-boundary.test.ts diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 599ea4332d5..ece9edab595 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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), diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index e7ad1858397..e6d81781d9d 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -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 + 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, onSynced?: (sessionID: string) => void) { const data = useData() const client = useClient() @@ -75,14 +95,22 @@ export function createSessionRows(sessionID: Accessor, 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) }), ) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index cce439f4e54..2ff9e6d9217 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 + let client!: ReturnType + const synced: SessionRow[] = [] + + function Probe() { + client = useClient() + rows = createSessionRows( + () => sessionID, + () => synced.push(structuredClone(unwrap(rows[0]))), + ) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + 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" diff --git a/packages/tui/test/cli/tui/group-boundary.test.ts b/packages/tui/test/cli/tui/group-boundary.test.ts new file mode 100644 index 00000000000..5393041a94f --- /dev/null +++ b/packages/tui/test/cli/tui/group-boundary.test.ts @@ -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) +})