Explorar o código

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

Brendan Allan hai 3 semanas
pai
achega
55f4a2691a

+ 1 - 0
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,

+ 10 - 5
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),

+ 25 - 5
packages/app/src/context/server-session.ts

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

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

+ 14 - 7
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<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) {