From 55f4a2691ae9e72a84c821d789f0912353197cbe Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:44:03 +0800 Subject: [PATCH] fix(app): preserve paginated timeline order (#38641) --- .../session-todo-dock-navigation.spec.ts | 1 + .../app/src/context/server-session.test.ts | 15 ++++++---- packages/app/src/context/server-session.ts | 30 +++++++++++++++---- .../session/timeline/rows-current.test.ts | 26 +++++++++++----- .../app/src/pages/session/timeline/rows.ts | 21 ++++++++----- 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 603c411d55..43f3500899 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -56,6 +56,7 @@ test("animates todo lifecycle without replaying it across session tabs", async ( default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], + sessionStatus: { [sourceID]: { type: "busy" } }, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 30723ecfbf..1e1046fb76 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -263,7 +263,7 @@ describe("server session", () => { expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) }) - test("reprojects current assistants when an older page supplies their user", async () => { + test("extends a current page to include the user for split assistant turns", async () => { const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const const assistant = (id: string, created: number) => ({ id, @@ -282,17 +282,22 @@ describe("server session", () => { { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, { data: [assistants[0], user], cursor: { previous: null, next: null } }, ] + const requests: unknown[] = [] const messageApi = { - list: async () => pages.shift()!, + list: async (input: unknown) => { + requests.push(input) + return pages.shift()! + }, } as unknown as MessageApi const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) store.remember(session("root")) await store.sync("root") - expect(store.data.message.root).toEqual([]) - - await store.history.loadMore("root") + expect(requests).toEqual([ + { sessionID: "root", limit: 20, order: "desc" }, + { sessionID: "root", limit: 20, cursor: "older" }, + ]) expect(store.data.message.root.map((message) => message.id)).toEqual([ user.id, ...assistants.map((item) => item.id), diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 6bf0f47f5c..e8f91cda3f 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -30,6 +30,17 @@ const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() +function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { + const boundary = source.find( + (message) => + message.type === "user" || + message.type === "shell" || + message.type === "assistant" || + (message.type === "synthetic" && message.description?.trim()), + ) + return boundary?.type === "assistant" +} + type OptimisticItem = { message: Message parts: Part[] @@ -525,11 +536,20 @@ export function createServerSession( const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { if (messageApi && (await options?.protocol) !== "v1") { - const response = await (options?.retry ?? retry)(() => { - onAttempt?.() - return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" }) - }) - const source = [...response.data].reverse() + const request = (cursor?: string) => + (options?.retry ?? retry)(() => { + onAttempt?.() + return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) + }) + const first = await request(before) + const pages = [first] + while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { + const response = await request(pages.at(-1)!.cursor.next ?? undefined) + pages.push(response) + if (!response.data.length) break + } + const response = pages.at(-1)! + const source = pages.flatMap((page) => page.data).toReversed() const normalized = normalizeSessionMessages(sessionID, source) return { session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index f5c74f5acb..b321ef8750 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -90,23 +90,32 @@ describe("current session timeline rows", () => { ]) }) - test("associates assistants with a projected parent missing from the source page", () => { + test("keeps a projected parent missing from the source page before newer turns", () => { const source = [ - { id: "msg_user", type: "user", text: "question", time: { created: 1 } }, + { id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } }, { - id: "msg_assistant", + id: "msg_assistant_1", type: "assistant", agent: "build", model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "answer" }], + content: [{ type: "text", text: "first answer" }], time: { created: 2, completed: 3 }, }, + { id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } }, + { + id: "msg_assistant_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "second answer" }], + time: { created: 5, completed: 6 }, + }, ] satisfies SessionMessageInfo[] const normalized = normalizeSessionMessages("ses_1", source) const messages = new Map(normalized.messages.map((message) => [message.id, message])) const result = Timeline.constructSessionMessageRows( - [source[1]!], + source.slice(1), (messageID) => messages.get(messageID), (messageID) => normalized.parts.get(messageID) ?? [], true, @@ -115,8 +124,11 @@ describe("current session timeline rows", () => { ) expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_user", - "assistant-part:msg_user:msg_assistant:text:0", + "user-message:msg_user_1", + "assistant-part:msg_user_1:msg_assistant_1:text:0", + "turn-gap:msg_user_2", + "user-message:msg_user_2", + "assistant-part:msg_user_2:msg_assistant_2:text:0", ]) }) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 2f05910d9e..f41dff7a34 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -40,17 +40,24 @@ export namespace Timeline { status: SessionStatus["type"], inlineComments: boolean, ) { - const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => { + const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] + const turnByUserID = new Map() + messages.forEach((message) => { const projected = getMessage(message.id) if (message.type === "shell" && projected?.role === "user") { const assistant = getMessage(`${message.id}:assistant`) - return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }] + const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] } + turns.push(turn) + turnByUserID.set(projected.id, turn) + return + } + if (projected?.role === "user") { + if (turnByUserID.has(projected.id)) return + const turn = { user: projected, assistants: [] } + turns.push(turn) + turnByUserID.set(projected.id, turn) + return } - return projected?.role === "user" ? [{ user: projected, assistants: [] }] : [] - }) - const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn])) - messages.forEach((message) => { - const projected = getMessage(message.id) if (projected?.role !== "assistant") return const existing = turnByUserID.get(projected.parentID) if (existing) {