Explorar o código

fix(app): hydrate timeline message parents (#35269)

Luke Parker hai 1 mes
pai
achega
a12d50e15a

+ 146 - 0
packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts

@@ -0,0 +1,146 @@
+import type { Page } from "@playwright/test"
+import { expectSessionTitle } from "../../utils/waits"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+import { benchmark, expect, withBenchmarkPage } from "../benchmark"
+import { fixture } from "./session-timeline-stress.fixture"
+import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
+import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
+
+type ParentHydrationBenchmarkMode = "natural" | "candidate"
+
+const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
+if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
+const userID = "msg_parent_hydration_user"
+const user = {
+  ...fixture.messages[fixture.targetID][0]!,
+  info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
+  parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
+    ...part,
+    id: `prt_parent_hydration_user_${index}`,
+    messageID: userID,
+  })),
+}
+const assistantSeed = fixture.messages[fixture.targetID][3]!
+const assistants = Array.from({ length: 14 }, (_, index) => {
+  const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
+  return {
+    ...assistantSeed,
+    info: {
+      ...assistantSeed.info,
+      id: messageID,
+      parentID: userID,
+      time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
+    },
+    parts: assistantSeed.parts.map((part, partIndex) => ({
+      ...part,
+      id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
+      messageID,
+    })),
+  }
+})
+const messages = [user, ...assistants]
+const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
+const lastID = userID
+const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
+
+benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
+  benchmark.setTimeout(180_000)
+  const results = [] as Awaited<ReturnType<typeof trial>>[]
+  for (let run = 0; run < 5; run++) {
+    results.push(
+      await withBenchmarkPage(browser, `session-parent-hydration-${mode}-${run}`, (page) => trial(page, mode), testInfo),
+    )
+  }
+  const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
+  report(
+    {
+      results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
+      summary: {
+        firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
+        blankSamples: results.map((result) => result.metrics.blankSamples),
+        requestCounts: {
+          list: results.map((result) => result.requestCounts.list),
+          parent: results.map((result) => result.requestCounts.parent),
+        },
+        historyGateCount: results.map((result) => result.historyGateCount),
+      },
+    },
+    { mode },
+  )
+})
+
+async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
+  const requests: { type: "list" | "parent"; before?: string }[] = []
+  const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
+  let historyGates = 0
+  await mockOpenCodeServer(page, {
+    sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
+    provider: fixture.provider,
+    directory: fixture.directory,
+    project: fixture.project,
+    messageDelay: 50,
+    onMessages: (request) => {
+      if (request.sessionID === fixture.targetID && request.phase === "start")
+        requests.push({ type: "list", before: request.before })
+    },
+    beforeMessagesResponse: (request) => {
+      if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
+      historyGates++
+      return history!.promise
+    },
+    onMessage: (request) => {
+      if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
+    },
+    message: (sessionID, messageID) => {
+      if (sessionID !== fixture.targetID || messageID !== userID) return
+      return user
+    },
+    pageMessages: (sessionID, limit, before) => {
+      const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
+      const end = before ? items.findIndex((message) => message.info.id === before) : items.length
+      const start = Math.max(0, end - limit)
+      return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
+    },
+  })
+  await page.route(`**/session/${fixture.targetID}`, (route) =>
+    route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
+  )
+  await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
+  await page.goto(stressSessionHref(fixture.sourceID))
+  await expectSessionTitle(page, fixture.expected.sourceTitle)
+  await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
+
+  const href = stressSessionHref(fixture.targetID)
+  await page.evaluate(
+    ({ href, title }) => {
+      const link = document.createElement("a")
+      link.id = "parent-hydration-target"
+      link.href = href
+      link.textContent = title
+      document.body.append(link)
+    },
+    { href, title: target.title },
+  )
+  const metrics = await measureSessionSwitch(page, {
+    destinationIDs: messages.map((message) => message.info.id),
+    sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
+    lastID,
+    requiredPartID: lastPartID,
+    requireBottomAnchor: false,
+    href,
+    switch: async () => {
+      await page.locator("#parent-hydration-target").click()
+      await expectSessionTitle(page, target.title)
+    },
+  }).finally(() => history?.resolve())
+  expect(metrics.firstCorrectObservedMs).not.toBeNull()
+  const requestCounts = {
+    list: requests.filter((request) => request.type === "list").length,
+    parent: requests.filter((request) => request.type === "parent").length,
+  }
+  if (mode === "candidate") {
+    expect(requestCounts.parent).toBe(1)
+    expect(historyGates).toBe(1)
+  }
+  return { metrics, requestCounts, historyGateCount: historyGates }
+}

+ 4 - 1
packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts

@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
   source: string[]
   hasVisibleRows: boolean
   last: boolean
+  requiredPartVisible?: boolean
+  bottomAnchorRequired?: boolean
   bottomErrorPx?: number
   review?: {
     fileHost: boolean
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
     sample.destination.length > 0 &&
     sample.source.length === 0 &&
     sample.last &&
-    Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
+    sample.requiredPartVisible !== false &&
+    (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
   )
 }
 

+ 48 - 8
packages/app/e2e/performance/timeline/session-tab-switch-probe.ts

@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
 
 async function installSessionSwitchProbe(
   page: Page,
-  input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
+  input: {
+    destinationIDs: string[]
+    sourceIDs: string[]
+    lastID: string
+    requiredPartID?: string
+    requireBottomAnchor?: boolean
+    href: string
+  },
 ) {
-  await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
+  await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
     const destination = new Set(destinationIDs)
     const source = new Set(sourceIDs)
     const samples: SessionSwitchSample[] = []
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
             const rect = element.getBoundingClientRect()
             return rect.bottom > view.top && rect.top < view.bottom
           })
+          const requiredPartVisible = requiredPartID
+            ? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
+                if (element.dataset.timelinePartId !== requiredPartID) return false
+                const rect = element.getBoundingClientRect()
+                return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
+              })
+            : undefined
           const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
           samples.push({
             observedAtMs,
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
             source: visible.filter((id) => source.has(id)),
             hasVisibleRows,
             last: visible.includes(lastID),
+            requiredPartVisible,
+            bottomAnchorRequired: requireBottomAnchor !== false,
             bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
             review,
           })
         } else {
-          samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
+          samples.push({
+            observedAtMs,
+            destination: [],
+            source: [],
+            hasVisibleRows: false,
+            last: false,
+            requiredPartVisible: requiredPartID ? false : undefined,
+            bottomAnchorRequired: requireBottomAnchor !== false,
+            review,
+          })
         }
         requestAnimationFrame(sample)
       }, 0)
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
             sample.destination.length > 0 &&
             sample.source.length === 0 &&
             sample.last &&
-            Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
+            sample.requiredPartVisible !== false &&
+            (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
         )
       )
     })
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
 
 export async function measureSessionSwitch(
   page: Page,
-  input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
+  input: {
+    destinationIDs: string[]
+    sourceIDs: string[]
+    lastID: string
+    requiredPartID?: string
+    requireBottomAnchor?: boolean
+    href: string
+    switch: () => Promise<void>
+  },
 ) {
   const { switch: run, ...probe } = input
   await installSessionSwitchProbe(page, probe)
-  await run()
-  await waitForStableSessionSwitch(page)
-  return collectSessionSwitchResult(page)
+  try {
+    await run()
+    await waitForStableSessionSwitch(page)
+    return await collectSessionSwitchResult(page)
+  } finally {
+    await page.evaluate(() => {
+      ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
+    })
+  }
 }
 
 export async function waitForStableTimeline(page: Page, lastID: string) {

+ 46 - 0
packages/app/e2e/performance/unit/mock-server.test.ts

@@ -0,0 +1,46 @@
+import { expect, test } from "bun:test"
+import type { Page, Route } from "@playwright/test"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+
+test("applies message latency after a list response gate is released", async () => {
+  const events: string[] = []
+  const gate = Promise.withResolvers<void>()
+  let handler: ((route: Route) => Promise<void>) | undefined
+  const page = {
+    route: (_url: string, callback: (route: Route) => Promise<void>) => {
+      handler = callback
+      return Promise.resolve()
+    },
+  } as unknown as Page
+  await mockOpenCodeServer(page, {
+    provider: {},
+    directory: "C:/OpenCode",
+    project: {},
+    sessions: [{ id: "session" }],
+    messageDelay: 25,
+    beforeMessagesResponse: () => {
+      events.push("before")
+      return gate.promise
+    },
+    onMessages: (request) => events.push(request.phase),
+    pageMessages: () => {
+      events.push("page")
+      return { items: [] }
+    },
+  })
+
+  const response = handler!({
+    request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
+    fulfill: () => {
+      events.push("fulfill")
+      return Promise.resolve()
+    },
+  } as unknown as Route)
+  expect(events).toEqual(["start", "before"])
+
+  const released = performance.now()
+  gate.resolve()
+  await response
+  expect(performance.now() - released).toBeGreaterThanOrEqual(20)
+  expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
+})

+ 32 - 0
packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts

@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
   expect(result.firstCorrectObservedMs).toBeNull()
   expect(result.stableObservedMs).toBeNull()
 })
+
+test("requires an explicitly tracked part to be visible", () => {
+  const result = classifySessionSwitch([
+    {
+      observedAtMs: 16,
+      destination: ["destination"],
+      source: [],
+      hasVisibleRows: true,
+      last: true,
+      requiredPartVisible: false,
+      bottomErrorPx: 0,
+    },
+  ])
+
+  expect(result.firstCorrectObservedMs).toBeNull()
+})
+
+test("can measure content correctness without requiring a bottom anchor", () => {
+  const result = classifySessionSwitch([
+    {
+      observedAtMs: 16,
+      destination: ["destination"],
+      source: [],
+      hasVisibleRows: true,
+      last: true,
+      requiredPartVisible: true,
+      bottomAnchorRequired: false,
+    },
+  ])
+
+  expect(result.firstCorrectObservedMs).toBe(16)
+})

+ 45 - 0
packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts

@@ -0,0 +1,45 @@
+import { expect, test } from "bun:test"
+import type { Page } from "@playwright/test"
+import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
+
+function testPage(waitFailure?: Error) {
+  const stops: unknown[] = []
+  const page = {
+    evaluate: async (_callback: unknown, input?: unknown) => {
+      if (input) return
+      stops.push(undefined)
+    },
+    waitForFunction: async () => {
+      if (waitFailure) throw waitFailure
+    },
+  } as unknown as Page
+  return { page, stops }
+}
+
+function input(run: () => Promise<void>) {
+  return {
+    destinationIDs: ["destination"],
+    sourceIDs: ["source"],
+    lastID: "destination",
+    href: "/session/destination",
+    switch: run,
+  }
+}
+
+test("stops sampling when the session switch fails", async () => {
+  const failure = new Error("switch failed")
+  const context = testPage()
+
+  await expect(measureSessionSwitch(context.page, input(async () => Promise.reject(failure)))).rejects.toBe(failure)
+
+  expect(context.stops).toHaveLength(1)
+})
+
+test("stops sampling when the stable wait fails", async () => {
+  const failure = new Error("stable wait failed")
+  const context = testPage(failure)
+
+  await expect(measureSessionSwitch(context.page, input(async () => {}))).rejects.toBe(failure)
+
+  expect(context.stops).toHaveLength(1)
+})

+ 220 - 0
packages/app/e2e/regression/session-timeline-history-root.spec.ts

@@ -0,0 +1,220 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import {
+  assistantMessage,
+  directory,
+  messageUpdated,
+  project,
+  session,
+  sessionID,
+  status,
+  textPart,
+  title,
+  userID,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { installSseTransport } from "../utils/sse-transport"
+import { expectSessionTitle } from "../utils/waits"
+
+const assistants = Array.from({ length: 14 }, (_, index) =>
+  assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
+    id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
+    parentID: userID,
+    created: 1700000001000 + index * 1_000,
+    completed: index < 13,
+  }),
+)
+const messages = [userMessage(), ...assistants]
+const lastAssistant = assistants.at(-1)!
+const lastPartID = assistants.at(-1)!.parts[0]!.id
+const userPartID = `prt_${userID}_text`
+const completed = {
+  ...lastAssistant.info,
+  time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
+}
+const scenarios = [
+  { name: "completion", info: completed, idleFirst: false, interrupted: false },
+  {
+    name: "interruption",
+    info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
+    idleFirst: true,
+    interrupted: true,
+  },
+] as const
+
+test.use({ viewport: { width: 646, height: 1385 } })
+
+for (const scenario of scenarios) {
+  test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
+    const requests: { before?: string; phase: "start" | "end" }[] = []
+    const pages: { before?: string; limit: number }[] = []
+    const roots: { sessionID: string; messageID: string }[] = []
+    const sequence: string[] = []
+    const history = Promise.withResolvers<void>()
+    const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
+      server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
+      retry: 20,
+    })
+    await mockOpenCodeServer(page, {
+      directory,
+      project: project(),
+      provider: {
+        all: [
+          {
+            id: "opencode",
+            name: "OpenCode",
+            models: {
+              "claude-opus-4-6": {
+                id: "claude-opus-4-6",
+                name: "Claude Opus 4.6",
+                limit: { context: 200_000 },
+              },
+            },
+          },
+        ],
+        connected: ["opencode"],
+        default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+      },
+      sessions: [session()],
+      sessionStatus: { [sessionID]: { type: "busy" } },
+      beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
+      onMessages: (request) => {
+        requests.push(request)
+        sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
+      },
+      onMessage: (request) => {
+        roots.push(request)
+        sequence.push(`message:${request.messageID}`)
+      },
+      message: (requestedSessionID, messageID) => {
+        if (requestedSessionID !== sessionID) return
+        return messages.find((item) => item.info.id === messageID)
+      },
+      pageMessages: (_, limit, before) => {
+        pages.push({ before, limit })
+        const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
+        const start = Math.max(0, end - limit)
+        return {
+          items: messages.slice(start, end),
+          cursor: start > 0 ? messages[start]!.info.id : undefined,
+        }
+      },
+    })
+    await page.addInitScript(
+      ({ userPartID, lastPartID }) => {
+        const state = { armed: false, hidden: false, samples: 0, stop: false }
+        ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
+        const sample = () => {
+          if (state.armed) {
+            const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
+            const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
+            const view = viewport?.getBoundingClientRect()
+            const visible = (partID: string) => {
+              const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
+              const rect = part?.getBoundingClientRect()
+              return (
+                !!rect &&
+                !!view &&
+                rect.width > 0 &&
+                rect.height > 0 &&
+                rect.bottom > view.top &&
+                rect.top < view.bottom
+              )
+            }
+            if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
+            state.samples++
+          }
+          if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
+        }
+        requestAnimationFrame(() => setTimeout(sample, 0))
+      },
+      { userPartID, lastPartID },
+    )
+
+    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+    await transport.waitForConnection()
+    await expectSessionTitle(page, title)
+    await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
+    await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
+    await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
+    expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
+    expect(sequence.slice(0, 4)).toEqual([
+      "messages:start:latest",
+      "messages:end:latest",
+      `message:${userID}`,
+      `messages:start:${messages.at(-2)!.info.id}`,
+    ])
+    await page.evaluate(() => {
+      ;(
+        window as Window & {
+          __historyRootProbe?: { armed: boolean }
+        }
+      ).__historyRootProbe!.armed = true
+    })
+    await waitForProbeSamples(page, 0)
+    expect(await historyRootHidden(page)).toBe(false)
+    const beforeHistory = await probeSamples(page)
+    history.resolve()
+    await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
+    await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
+    await waitForProbeSamples(page, beforeHistory)
+    expect(pages[0]).toEqual({ before: undefined, limit: 2 })
+    expect(roots).toEqual([{ sessionID, messageID: userID }])
+
+    const message = messageUpdated(scenario.info)
+    const idle = status("idle")
+    for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
+      const beforeEvent = await probeSamples(page)
+      await transport.send(event)
+      if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
+      if (event === message && scenario.interrupted)
+        await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
+      await waitForProbeSamples(page, beforeEvent)
+      const current = await timelineState(page)
+      expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
+      expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
+    }
+
+    expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
+    expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
+    await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
+    await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
+    if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
+    expect(
+      await page.evaluate(() => {
+        const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
+          .__historyRootProbe!
+        state.stop = true
+        return state.hidden
+      }),
+    ).toBe(false)
+  })
+}
+
+function timelineState(page: Page) {
+  return page.evaluate(() => ({
+    virtual: !!document.querySelector("[data-timeline-virtual-content]"),
+    rows: document.querySelectorAll("[data-timeline-key]").length,
+  }))
+}
+
+function probeSamples(page: Page) {
+  return page.evaluate(
+    () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
+  )
+}
+
+async function waitForProbeSamples(page: Page, after: number) {
+  await page.waitForFunction(
+    (after) =>
+      (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
+    after,
+  )
+}
+
+function historyRootHidden(page: Page) {
+  return page.evaluate(
+    () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
+  )
+}

+ 14 - 1
packages/app/e2e/utils/mock-server.ts

@@ -11,7 +11,10 @@ export interface MockServerConfig {
   pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
   vcsDiff?: unknown[]
   messageDelay?: number
+  beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
   onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
+  message?: (sessionID: string, messageID: string) => unknown
+  onMessage?: (input: { sessionID: string; messageID: string }) => void
   events?: () => unknown[]
   eventRetry?: number
   todos?: (sessionID: string) => unknown[]
@@ -72,6 +75,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, session ?? {})
     }
 
+    const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
+    if (messageMatch) {
+      config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
+      if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
+      return json(route, message)
+    }
+
     const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
     if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
     if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
@@ -82,7 +94,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       const before = token ? cursors.get(token) : undefined
       if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
-      if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
       const limit = Number(url.searchParams.get("limit") ?? 80)
       const pageData = config.pageMessages(messagesMatch[1], limit, before)
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })

+ 308 - 0
packages/app/src/context/server-session.test.ts

@@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({
 })
 
 type UserMessage = Extract<Message, { role: "user" }>
+type AssistantMessage = Extract<Message, { role: "assistant" }>
 type TextPart = Extract<Part, { type: "text" }>
 type MessageResponse = {
   data: { info: Message; parts: Part[] }[]
   response: { headers: Headers }
 }
+type SingleMessageResponse = { data: MessageResponse["data"][number] }
 
 const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
   id,
@@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
   ...input,
 })
 
+const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
+  id,
+  sessionID: "child",
+  role: "assistant",
+  time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
+  parentID,
+  modelID: "model",
+  providerID: "provider",
+  mode: "build",
+  agent: "build",
+  path: { cwd: "/repo", root: "/repo" },
+  cost: 0,
+  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+  ...input,
+})
+
 const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
   id: "part",
   sessionID: "child",
@@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
   response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
 })
 
+const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
+
 const deferredResponse = () => Promise.withResolvers<MessageResponse>()
 
 function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
@@ -71,6 +91,40 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
   })
 }
 
+function rootMessageClient(
+  pages: Array<MessageResponse | Promise<MessageResponse>>,
+  roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
+) {
+  let pageIndex = 0
+  let rootIndex = 0
+  const requests: unknown[] = []
+  const rootRequests: unknown[] = []
+  const rootWaiting = new Map<number, () => void>()
+  const client = {
+    session: {
+      get: async () => ({ data: session("child", "root") }),
+      messages: (input: unknown) => {
+        requests.push(input)
+        return pages[pageIndex++]
+      },
+      message: (input: unknown) => {
+        rootRequests.push(input)
+        rootWaiting.get(rootRequests.length)?.()
+        rootWaiting.delete(rootRequests.length)
+        return roots[rootIndex++]
+      },
+    },
+  } as unknown as OpencodeClient
+  return Object.assign(client, {
+    requests,
+    rootRequests,
+    rootRequested(count: number) {
+      if (rootRequests.length >= count) return Promise.resolve()
+      return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
+    },
+  })
+}
+
 const retryImmediately: typeof retry = async (task, options = {}) => {
   const attempts = options.attempts ?? 3
   for (let attempt = 0; ; attempt++) {
@@ -124,6 +178,240 @@ describe("server session", () => {
     expect(ctx.store.data.message.root).toEqual([])
   })
 
+  test("backfills an assistant-only initial page through its user root", async () => {
+    const user = userMessage("message-1")
+    const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
+    const client = rootMessageClient(
+      [
+        response(
+          assistants.map((info) => ({ info, parts: [] })),
+          "older",
+        ),
+      ],
+      [singleResponse(user)],
+    )
+    const store = createServerSession(client)
+
+    await store.sync("child")
+
+    expect(client.requests).toEqual([
+      { sessionID: "child", limit: 2, before: undefined },
+    ])
+    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
+    expect(store.data.message.child).toEqual([user, ...assistants])
+    expect(store.history.more("child")).toBe(true)
+  })
+
+  test("does not let an optimistic user suppress initial root backfill", async () => {
+    const user = userMessage("message-1")
+    const part = textPart(user.id)
+    const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
+    const client = rootMessageClient(
+      [
+        response(
+          assistants.map((info) => ({ info, parts: [] })),
+          "older",
+        ),
+      ],
+      [singleResponse(user)],
+    )
+    const store = createServerSession(client)
+    store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
+
+    await store.sync("child")
+    store.optimistic.remove({ sessionID: "child", messageID: user.id })
+
+    expect(client.requests).toHaveLength(1)
+    expect(client.rootRequests).toHaveLength(1)
+    expect(store.data.message.child).toEqual([user, ...assistants])
+  })
+
+  test("backfills the parent of fetched assistants when another user is cached", async () => {
+    const unrelated = userMessage("message-0", { time: { created: 0 } })
+    const user = userMessage("message-1")
+    const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
+    const client = rootMessageClient(
+      [
+        response([{ info: unrelated, parts: [] }]),
+        response(
+          assistants.map((info) => ({ info, parts: [] })),
+          "older",
+        ),
+      ],
+      [singleResponse(user)],
+    )
+    const store = createServerSession(client)
+    await store.sync("child")
+
+    await store.sync("child", { force: true })
+
+    expect(client.requests).toHaveLength(2)
+    expect(client.rootRequests).toHaveLength(1)
+    expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
+  })
+
+  test("preserves cached history between an injected parent and the page boundary", async () => {
+    const user = userMessage("message-1")
+    const cached = userMessage("message-3", { time: { created: 3 } })
+    const assistant = assistantMessage("message-4", user.id)
+    const client = rootMessageClient(
+      [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
+      [singleResponse(user)],
+    )
+    const store = createServerSession(client)
+    await store.sync("child")
+
+    await store.sync("child", { force: true })
+
+    expect(store.data.message.child).toEqual([user, cached, assistant])
+  })
+
+  test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
+    const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
+    const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
+    const stalePart = textPart(stale.id, { text: "stale" })
+    const freshPart = { ...stalePart, text: "fresh" }
+    const assistant = assistantMessage("message-2", stale.id)
+    const client = rootMessageClient(
+      [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
+      [singleResponse(fresh, [freshPart])],
+    )
+    const store = createServerSession(client)
+    await store.sync("child")
+
+    await store.sync("child", { force: true })
+
+    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
+    expect(store.data.message.child).toEqual([fresh, assistant])
+    expect(store.data.part[stale.id]).toEqual([freshPart])
+  })
+
+  test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
+    const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
+    const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
+    const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
+    const refreshed = { ...confirmed, text: "fresh" }
+    const pending = textPart(stale.id, { id: "pending", text: "pending" })
+    const assistant = assistantMessage("message-2", stale.id)
+    const client = rootMessageClient(
+      [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
+      [singleResponse(fresh, [refreshed])],
+    )
+    const store = createServerSession(client)
+    store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
+    await store.sync("child")
+
+    await store.sync("child", { force: true })
+
+    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
+    expect(store.data.message.child).toEqual([fresh, assistant])
+    expect(store.data.part[stale.id]).toEqual([refreshed, pending])
+  })
+
+  test("uses a parent received by SSE during the replacement load", async () => {
+    const pending = deferredResponse()
+    const user = userMessage("message-1")
+    const assistant = assistantMessage("message-2", user.id)
+    const client = rootMessageClient([pending.promise], [])
+    const store = createServerSession(client)
+    const loading = store.sync("child")
+
+    store.apply({ type: "message.updated", properties: { info: user } })
+    pending.resolve(response([{ info: assistant, parts: [] }], "older"))
+    await loading
+
+    expect(client.rootRequests).toEqual([])
+    expect(store.data.message.child).toEqual([user, assistant])
+  })
+
+  test("uses a successful retry over events received by a failed backfill attempt", async () => {
+    const failed = deferredResponse()
+    const user = userMessage("message-1")
+    const live = { ...user, agent: "stale" }
+    const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
+    const client = rootMessageClient(
+      [
+        response(
+          assistants.map((info) => ({ info, parts: [] })),
+          "older",
+        ),
+      ],
+      [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
+    )
+    const store = createServerSession(client, { retry: retryImmediately })
+    const loading = store.sync("child")
+    await client.rootRequested(1)
+
+    store.apply({ type: "message.updated", properties: { info: live } })
+    failed.reject(new Error("retry"))
+    await loading
+
+    expect(client.requests).toHaveLength(1)
+    expect(client.rootRequests).toHaveLength(2)
+    expect(store.data.message.child).toEqual([user, ...assistants])
+  })
+
+  test("preserves newer-page events across a failed parent retry", async () => {
+    const failed = deferredResponse()
+    const user = userMessage("message-1")
+    const assistant = assistantMessage("message-2", user.id)
+    const live = { ...assistant, cost: 1 }
+    const client = rootMessageClient(
+      [response([{ info: assistant, parts: [] }], "older")],
+      [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
+    )
+    const store = createServerSession(client, { retry: retryImmediately })
+    const loading = store.sync("child")
+    await client.rootRequested(1)
+
+    store.apply({ type: "message.updated", properties: { info: live } })
+    failed.reject(new Error("retry"))
+    await loading
+
+    expect(store.data.message.child).toEqual([user, live])
+  })
+
+  test("preserves unrelated message events across a failed parent retry", async () => {
+    const failed = deferredResponse()
+    const user = userMessage("message-1")
+    const assistant = assistantMessage("message-2", user.id)
+    const live = userMessage("message-4", { time: { created: 4 } })
+    const client = rootMessageClient(
+      [response([{ info: assistant, parts: [] }], "older")],
+      [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
+    )
+    const store = createServerSession(client, { retry: retryImmediately })
+    const loading = store.sync("child")
+    await client.rootRequested(1)
+
+    store.apply({ type: "message.updated", properties: { info: live } })
+    failed.reject(new Error("retry"))
+    await loading
+
+    expect(store.data.message.child).toEqual([user, assistant, live])
+  })
+
+  test("preserves newer-page part events across a failed parent retry", async () => {
+    const failed = deferredResponse()
+    const user = userMessage("message-1")
+    const assistant = assistantMessage("message-2", user.id)
+    const stale = textPart(assistant.id, { text: "stale" })
+    const live = { ...stale, text: "live" }
+    const client = rootMessageClient(
+      [response([{ info: assistant, parts: [stale] }], "older")],
+      [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
+    )
+    const store = createServerSession(client, { retry: retryImmediately })
+    const loading = store.sync("child")
+    await client.rootRequested(1)
+
+    store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
+    failed.reject(new Error("retry"))
+    await loading
+
+    expect(store.data.part[assistant.id]).toEqual([live])
+  })
+
   test("merges live events into the initial page", async () => {
     const pending = deferredResponse()
     const user = userMessage("message-1")
@@ -905,6 +1193,26 @@ describe("server session", () => {
     expect(store.data.message.child).toEqual([latest])
   })
 
+  test("does not scan cached messages for user roots during history prepend", async () => {
+    const guard = { active: false }
+    const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
+      get(target, property, receiver) {
+        if (guard.active && property === "role") throw new Error("cached role accessed")
+        return Reflect.get(target, property, receiver)
+      },
+    })
+    const older = userMessage("message-1")
+    const store = createServerSession(
+      messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
+    )
+    await store.sync("child")
+    guard.active = true
+
+    await store.history.loadMore("child")
+
+    expect(store.data.message.child).toEqual([older, latest])
+  })
+
   test("preserves loaded history during an incomplete refresh", async () => {
     const older = userMessage("message-1")
     const latest = userMessage("message-2", { time: { created: 2 } })

+ 109 - 30
packages/app/src/context/server-session.ts

@@ -54,6 +54,11 @@ type MessageLoadState = {
   clearedMessageParts: Set<string>
 }
 
+type MessageLoadBaseline = Pick<
+  MessageLoadState,
+  "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
+>
+
 function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
   if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
   const session = [...page.session]
@@ -347,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     load.touchedParts.set(messageID, new Set([partID]))
   }
 
-  const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
+  const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => {
     load.touchedMessages.clear()
     load.retainedMessages.clear()
     load.touchedParts.clear()
@@ -380,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       parts.forEach((partID) => touched.add(partID))
       load.touchedParts.set(messageID, touched)
     }
+    baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
+    baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
+    baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
+    baseline?.touchedParts.forEach((parts, messageID) => {
+      const touched = load.touchedParts.get(messageID) ?? new Set<string>()
+      parts.forEach((partID) => touched.add(partID))
+      load.touchedParts.set(messageID, touched)
+    })
   }
 
+  const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({
+    touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)),
+    retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)),
+    touchedParts: new Map(
+      [...load.touchedParts]
+        .filter(([messageID]) => messageID !== exclude)
+        .map(([messageID, parts]) => [messageID, new Set(parts)]),
+    ),
+    clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)),
+  })
+
   const evict = (sessionIDs: string[]) => {
     if (sessionIDs.length === 0) return
     const evicted = new Set(sessionIDs)
@@ -460,6 +484,18 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     }
   }
 
+  const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
+    const response = await (options?.retry ?? retry)(() => {
+      onAttempt?.()
+      return client.session.message({ sessionID, messageID })
+    })
+    if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
+    return {
+      message: cleanMessage(response.data.info),
+      parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
+    }
+  }
+
   const replaceMessages = (sessionID: string, messages: Message[]) => {
     const messageIDs = new Set(messages.map((message) => message.id))
     const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
@@ -578,36 +614,79 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     messageLoads.set(sessionID, load)
     setMeta("loading", sessionID, true)
     let applied = false
-    await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
-      .then((page) => {
-        if (generations.get(sessionID) !== active) return
-        const first = page.session.reduce<Message | undefined>(
-          (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
-          undefined,
-        )
-        const preserveUnfetched =
-          mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
-        applyMessagePage(
-          sessionID,
-          page,
-          messageLoads.get(sessionID) === load ? load : undefined,
-          preserveUnfetched,
-          mode !== "prepend",
-        )
-        applied = true
-      })
-      .finally(() => {
-        if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
-          for (const messageID of load.orphanParents) {
-            if (!orphanParts.get(sessionID)?.has(messageID)) continue
-            setData(produce((draft) => deleteMessageParts(draft, messageID)))
-            orphanParts.get(sessionID)?.delete(messageID)
-          }
-          if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
+    try {
+      const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
+      const first = page.session.reduce<Message | undefined>(
+        (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
+        undefined,
+      )
+      if (generations.get(sessionID) !== active) return
+
+      const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[]
+      if (mode !== "prepend") {
+        const users = new Set([
+          ...page.session.filter((message) => message.role === "user").map((message) => message.id),
+          ...(data.message[sessionID] ?? [])
+            .filter((message) => {
+              if (message.role !== "user") return false
+              const item = optimistic.get(sessionID)?.get(message.id)
+              return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
+            })
+            .map((message) => message.id),
+        ])
+        const parentIDs = [
+          ...new Set(
+            page.session.flatMap((message) =>
+              message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
+            ),
+          ),
+        ]
+        for (const parentID of parentIDs) {
+          if (generations.get(sessionID) !== active) break
+          const parent = await fetchMessage(sessionID, parentID, () =>
+            resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
+          )
+          if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
+          parents.push(parent)
         }
-        if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
-        if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
-      })
+      }
+      if (generations.get(sessionID) !== active) return
+      const result =
+        mode === "prepend"
+          ? page
+          : {
+              ...page,
+              session: merge(
+                page.session,
+                parents.map((parent) => parent.message),
+              ),
+              part: merge(
+                page.part,
+                parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
+              ),
+            }
+      const preserveUnfetched =
+        mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
+      applyMessagePage(
+        sessionID,
+        result,
+        messageLoads.get(sessionID) === load ? load : undefined,
+        preserveUnfetched,
+        mode !== "prepend",
+      )
+      applied = true
+    } finally {
+      if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
+        for (const messageID of load.orphanParents) {
+          if (!orphanParts.get(sessionID)?.has(messageID)) continue
+          setData(produce((draft) => deleteMessageParts(draft, messageID)))
+          orphanParts.get(sessionID)?.delete(messageID)
+        }
+        if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
+      }
+      if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
+      if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
+    }
   }
 
   const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {

+ 7 - 1
packages/app/src/pages/session/timeline/model.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
-import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
+import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
 
 const user = (id: string) => ({ id, role: "user" }) as UserMessage
 const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
@@ -15,6 +15,12 @@ describe("timeline model", () => {
     expect(selectVisibleUserMessages(users)).toBe(users)
   })
 
+  test("waits for an assistant-only load to hydrate its user root", () => {
+    expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
+    expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
+    expect(isTimelineReady([], false)).toBe(true)
+  })
+
   test("loads exactly one opaque cursor page", async () => {
     let calls = 0
     const anchors: Array<string | boolean> = []

+ 5 - 1
packages/app/src/pages/session/timeline/model.ts

@@ -45,7 +45,7 @@ export function createTimelineModel(input: {
   })
   const ready = createMemo(() => {
     const id = input.sessionID()
-    return !id || sync().data.message[id] !== undefined
+    return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
   })
   const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
   const visibleUserMessages = createMemo(
@@ -98,6 +98,10 @@ export function selectUserMessages(messages: Message[]) {
   return messages.filter((message): message is UserMessage => message.role === "user")
 }
 
+export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
+  return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
+}
+
 export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
   if (!revertMessageID) return messages
   return messages.filter((message) => message.id < revertMessageID)