fix(app): preserve paginated timeline order (#38641)

This commit is contained in:
Brendan Allan 2026-07-24 15:44:03 +08:00 committed by GitHub
commit 55f4a2691a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 69 additions and 24 deletions

View file

@ -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,

View file

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

View file

@ -30,6 +30,17 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = 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)),

View file

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

View file

@ -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<string, (typeof turns)[number]>()
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) {