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
parent a48912cbb1
commit 55f4a2691a
5 changed files with 69 additions and 24 deletions
@@ -56,6 +56,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
default: { providerID: "opencode", modelID: "claude-opus-4-6" }, default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}, },
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
@@ -263,7 +263,7 @@ describe("server session", () => {
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) 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 user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({ const assistant = (id: string, created: number) => ({
id, id,
@@ -282,17 +282,22 @@ describe("server session", () => {
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
{ data: [assistants[0], user], cursor: { previous: null, next: null } }, { data: [assistants[0], user], cursor: { previous: null, next: null } },
] ]
const requests: unknown[] = []
const messageApi = { const messageApi = {
list: async () => pages.shift()!, list: async (input: unknown) => {
requests.push(input)
return pages.shift()!
},
} as unknown as MessageApi } as unknown as MessageApi
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
store.remember(session("root")) store.remember(session("root"))
await store.sync("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([ expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id, user.id,
...assistants.map((item) => item.id), ...assistants.map((item) => item.id),
+25 -5
View File
@@ -30,6 +30,17 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() 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 = { type OptimisticItem = {
message: Message message: Message
parts: Part[] parts: Part[]
@@ -525,11 +536,20 @@ export function createServerSession(
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
if (messageApi && (await options?.protocol) !== "v1") { if (messageApi && (await options?.protocol) !== "v1") {
const response = await (options?.retry ?? retry)(() => { const request = (cursor?: string) =>
onAttempt?.() (options?.retry ?? retry)(() => {
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" }) onAttempt?.()
}) return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
const source = [...response.data].reverse() })
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) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
@@ -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 = [ 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", type: "assistant",
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }], content: [{ type: "text", text: "first answer" }],
time: { created: 2, completed: 3 }, 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[] ] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source) const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message])) const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows( const result = Timeline.constructSessionMessageRows(
[source[1]!], source.slice(1),
(messageID) => messages.get(messageID), (messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [], (messageID) => normalized.parts.get(messageID) ?? [],
true, true,
@@ -115,8 +124,11 @@ describe("current session timeline rows", () => {
) )
expect(result.rows.map(TimelineRow.key)).toEqual([ expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user", "user-message:msg_user_1",
"assistant-part:msg_user:msg_assistant:text:0", "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",
]) ])
}) })
}) })
@@ -40,17 +40,24 @@ export namespace Timeline {
status: SessionStatus["type"], status: SessionStatus["type"],
inlineComments: boolean, 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) const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") { if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`) 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 if (projected?.role !== "assistant") return
const existing = turnByUserID.get(projected.parentID) const existing = turnByUserID.get(projected.parentID)
if (existing) { if (existing) {