소스 검색

fix(app): stabilize virtual session timeline interactions (#28422)

Luke Parker 3 달 전
부모
커밋
f023c63a60

+ 1 - 0
bun.lock

@@ -722,6 +722,7 @@
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+    "virtua@0.49.1": "patches/virtua@0.49.1.patch",
   },
   "overrides": {
     "@opentui/core": "catalog:",

+ 1 - 0
package.json

@@ -140,6 +140,7 @@
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
+    "virtua@0.49.1": "patches/virtua@0.49.1.patch",
     "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
   }
 }

+ 353 - 0
packages/app/e2e/regression/session-timeline-collapse-state.spec.ts

@@ -0,0 +1,353 @@
+import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
+
+const directory = "C:/OpenCode/TimelineStateRegression"
+const projectID = "proj_timeline_state_regression"
+const sessionID = "ses_timeline_state_regression"
+const userMessageID = "msg_user_regression"
+const assistantMessageID = "msg_assistant_regression"
+const editPartID = "prt_0001_edit"
+const textPartID = "prt_9999_text"
+const title = "Timeline collapse state regression"
+const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
+
+type EventPayload = {
+  directory: string
+  payload: Record<string, unknown>
+}
+
+declare global {
+  interface Window {
+    __timelineDiffProbe: {
+      reset: () => void
+      shadowRoots: () => number
+    }
+  }
+}
+
+const userMessage = {
+  info: {
+    id: userMessageID,
+    sessionID,
+    role: "user",
+    time: { created: 1700000000000 },
+    summary: { diffs: [] },
+    agent: "build",
+    model,
+  },
+  parts: [
+    {
+      id: "prt_user_text",
+      sessionID,
+      messageID: userMessageID,
+      type: "text",
+      text: "Please edit the file.",
+    },
+  ],
+}
+
+const editPart = {
+  id: editPartID,
+  sessionID,
+  messageID: assistantMessageID,
+  type: "tool",
+  callID: "call_edit_regression",
+  tool: "edit",
+  state: {
+    status: "completed",
+    input: { filePath: "src/regression.ts" },
+    output: "Edited src/regression.ts",
+    title: "src/regression.ts",
+    metadata: {
+      filediff: {
+        file: "src/regression.ts",
+        additions: 1,
+        deletions: 1,
+        before: "export const value = 'before'\n",
+        after: "export const value = 'after'\n",
+      },
+      diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
+    },
+    time: { start: 1700000001000, end: 1700000002000 },
+  },
+}
+
+const streamedTextPart = {
+  id: textPartID,
+  sessionID,
+  messageID: assistantMessageID,
+  type: "text",
+  text: "Streaming added a later assistant text part.",
+}
+
+const assistantMessage = {
+  info: {
+    id: assistantMessageID,
+    sessionID,
+    role: "assistant",
+    time: { created: 1700000001000 },
+    parentID: userMessageID,
+    modelID: model.modelID,
+    providerID: model.providerID,
+    mode: "build",
+    agent: "build",
+    path: { cwd: directory, root: directory },
+    cost: 0.01,
+    tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+    variant: "max",
+  },
+  parts: [editPart],
+}
+
+test.describe("regression: session timeline local row state", () => {
+  test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
+    const events: EventPayload[] = []
+    await mockServer(page, events)
+    await configurePage(page)
+
+    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+    await expect(page.getByRole("heading", { name: title })).toBeVisible()
+
+    const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
+    await expect(wrapper).toBeVisible()
+    await expectExpanded(wrapper, true)
+
+    await wrapper.evaluate((element) => {
+      ;(element as HTMLElement).dataset.regressionMarker = "before-stream"
+    })
+    await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
+    await expectExpanded(wrapper, false)
+
+    events.push({
+      directory,
+      payload: {
+        type: "message.part.updated",
+        properties: { part: streamedTextPart },
+      },
+    })
+
+    await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
+
+    expect(await readToolState(page)).toEqual({
+      expanded: false,
+      row: "AssistantPart",
+      streamedTextVisible: true,
+    })
+  })
+
+  test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
+    const events: EventPayload[] = []
+    await installDiffProbe(page)
+    await mockServer(page, events)
+    await configurePage(page)
+
+    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+    await expect(page.getByRole("heading", { name: title })).toBeVisible()
+
+    const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
+    await expect(wrapper).toBeVisible()
+    await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
+    await markDiffProbe(page)
+
+    events.push({
+      directory,
+      payload: {
+        type: "message.part.updated",
+        properties: { part: streamedTextPart },
+      },
+    })
+
+    await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
+    expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
+
+    await markDiffProbe(page)
+    events.push({
+      directory,
+      payload: {
+        type: "message.part.updated",
+        properties: { part: editPartWithAdditions(2) },
+      },
+    })
+
+    await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible({ timeout: 10_000 })
+    expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
+  })
+})
+
+async function configurePage(page: Page) {
+  await page.addInitScript(() => {
+    localStorage.setItem(
+      "settings.v3",
+      JSON.stringify({
+        general: {
+          editToolPartsExpanded: true,
+          shellToolPartsExpanded: true,
+          showReasoningSummaries: true,
+          showSessionProgressBar: true,
+        },
+      }),
+    )
+  })
+}
+
+async function expectExpanded(locator: Locator, expected: boolean) {
+  await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
+}
+
+async function readToolState(page: Page) {
+  return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element, textPartID) => ({
+    expanded: (() => {
+      const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
+      const aria = trigger?.getAttribute("aria-expanded")
+      if (aria === "true") return true
+      if (aria === "false") return false
+
+      const root = element.querySelector('[data-component="collapsible"]')
+      if (root?.hasAttribute("data-expanded")) return true
+      if (root?.hasAttribute("data-closed")) return false
+
+      const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
+      return !!content && content.getBoundingClientRect().height > 0
+    })(),
+    row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
+    streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
+  }), textPartID)
+}
+
+async function installDiffProbe(page: Page) {
+  await page.addInitScript(() => {
+    let shadowRootCount = 0
+    const attachShadow = Element.prototype.attachShadow
+    Element.prototype.attachShadow = function (init) {
+      shadowRootCount += 1
+      return attachShadow.call(this, init)
+    }
+    window.__timelineDiffProbe = {
+      reset: () => {
+        shadowRootCount = 0
+      },
+      shadowRoots: () => shadowRootCount,
+    }
+  })
+}
+
+async function markDiffProbe(page: Page) {
+  await page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element) => {
+    const tool = element as HTMLElement
+    const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
+    if (!file) throw new Error("missing edit diff file")
+
+    tool.dataset.timelineProbe = "before"
+    file.dataset.timelineProbe = "before"
+    window.__timelineDiffProbe.reset()
+  })
+}
+
+async function readDiffProbe(page: Page) {
+  return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element) => {
+    const tool = element as HTMLElement
+    const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
+    return {
+      fileMarker: file?.dataset.timelineProbe,
+      shadowRoots: window.__timelineDiffProbe.shadowRoots(),
+      toolMarker: tool.dataset.timelineProbe,
+    }
+  })
+}
+
+function editPartWithAdditions(additions: number) {
+  return {
+    ...editPart,
+    state: {
+      ...editPart.state,
+      metadata: {
+        ...editPart.state.metadata,
+        filediff: {
+          ...editPart.state.metadata.filediff,
+          additions,
+        },
+      },
+    },
+  }
+}
+
+function readExpanded(element: Element) {
+  const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
+  const aria = trigger?.getAttribute("aria-expanded")
+  if (aria === "true") return true
+  if (aria === "false") return false
+
+  const root = element.querySelector('[data-component="collapsible"]')
+  if (root?.hasAttribute("data-expanded")) return true
+  if (root?.hasAttribute("data-closed")) return false
+
+  const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
+  return !!content && content.getBoundingClientRect().height > 0
+}
+
+async function mockServer(page: Page, events: EventPayload[]) {
+  await page.route("**/*", async (route) => {
+    const url = new URL(route.request().url())
+    const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
+    if (url.port !== targetPort) return route.fallback()
+
+    const path = url.pathname
+    if (path === "/global/event") return sse(route, events.splice(0))
+    if (path === "/global/config" || path === "/config" || path === "/provider/auth" || path === "/mcp" || path === "/session/status") return json(route, {})
+    if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, [])
+    if (path === "/provider") return json(route, provider())
+    if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
+    if (path === "/project") return json(route, [project()])
+    if (path === "/project/current") return json(route, project())
+    if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
+    if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    if (path === "/session") return json(route, [session()])
+    if (path === `/session/${sessionID}`) return json(route, session())
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
+    if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
+    return json(route, {})
+  })
+}
+
+function project() {
+  return { id: projectID, worktree: directory, vcs: "git", name: "timeline-state-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }
+}
+
+function session() {
+  return { id: sessionID, slug: "timeline-state-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }
+}
+
+function provider() {
+  return {
+    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" },
+  }
+}
+
+function json(route: Route, body: unknown, headers?: Record<string, string>) {
+  return route.fulfill({
+    status: 200,
+    contentType: "application/json",
+    headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
+    body: JSON.stringify(body ?? null),
+  })
+}
+
+function sse(route: Route, events: EventPayload[]) {
+  return route.fulfill({
+    status: 200,
+    contentType: "text/event-stream",
+    headers: { "access-control-allow-origin": "*" },
+    body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
+  })
+}
+
+function base64Encode(value: string) {
+  return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}

+ 277 - 0
packages/app/e2e/regression/session-timeline-context-resize.spec.ts

@@ -0,0 +1,277 @@
+import { expect, test, type Page, type Route } from "@playwright/test"
+
+const directory = "C:/OpenCode/ContextResizeRegression"
+const projectID = "proj_context_resize_regression"
+const sessionID = "ses_context_resize_regression"
+const title = "Context resize regression"
+const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
+const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
+const followingTextID = "prt_0104_text"
+
+type Message = { info: Record<string, unknown> & { id: string; role: "user" | "assistant" }; parts: Record<string, unknown>[] }
+
+const messages = [
+  ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
+  ...turn(10, true),
+]
+
+test.describe("regression: session timeline context group resize", () => {
+  test("remeasures a recent explored context group before the next paint", async ({ page }) => {
+    await page.setViewportSize({ width: 1400, height: 900 })
+    await mockServer(page)
+    await configurePage(page)
+
+    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+    await expect(page.getByRole("heading", { name: title })).toBeVisible()
+    await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
+    await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
+    await settle(page)
+
+    const samples = await sampleExpansion(page)
+    const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
+
+    console.log("context resize samples", JSON.stringify(samples, null, 2))
+
+    expect(samples[0]?.overlap).toBe(0)
+    expect(visibleOverlap).toEqual([])
+    expect(samples.at(-1)?.expanded).toBe("true")
+  })
+})
+
+async function configurePage(page: Page) {
+  await page.addInitScript(() => {
+    localStorage.setItem(
+      "settings.v3",
+      JSON.stringify({
+        general: {
+          editToolPartsExpanded: true,
+          shellToolPartsExpanded: true,
+          showReasoningSummaries: true,
+          showSessionProgressBar: true,
+        },
+      }),
+    )
+  })
+}
+
+async function sampleExpansion(page: Page) {
+  return page.evaluate(
+    ({ contextIDs, followingTextID }) =>
+      new Promise<
+        {
+          frame: number
+          label: string
+          scrollTop: number
+          scrollHeight: number
+          contextBottom: number
+          textTop: number
+          overlap: number
+          gap: number
+          expanded: string | null
+        }[]
+      >((resolve) => {
+        const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
+        const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
+        const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
+        const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
+        const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
+        const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
+        if (!context || !text || !scroller || !trigger || !contextRow || !textRow) throw new Error("missing regression nodes")
+
+        scroller.scrollTop = scroller.scrollHeight
+        const samples: {
+          frame: number
+          label: string
+          scrollTop: number
+          scrollHeight: number
+          contextBottom: number
+          textTop: number
+          overlap: number
+          gap: number
+          expanded: string | null
+        }[] = []
+        const capture = (frame: number, label: string) => {
+          const contextRect = contextRow.getBoundingClientRect()
+          const textRect = textRow.getBoundingClientRect()
+          samples.push({
+            frame,
+            label,
+            scrollTop: Math.round(scroller.scrollTop * 10) / 10,
+            scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
+            contextBottom: Math.round(contextRect.bottom * 10) / 10,
+            textTop: Math.round(textRect.top * 10) / 10,
+            overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
+            gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
+            expanded: trigger.getAttribute("aria-expanded"),
+          })
+        }
+
+        capture(-1, "before")
+        trigger.click()
+        capture(0, "sync-after-click")
+
+        let frame = 1
+        const tick = () => {
+          capture(frame, "raf")
+          frame += 1
+          if (frame > 8) {
+            resolve(samples)
+            return
+          }
+          requestAnimationFrame(tick)
+        }
+        requestAnimationFrame(tick)
+      }),
+    { contextIDs, followingTextID },
+  )
+}
+
+function turn(index: number, target: boolean): Message[] {
+  const userID = id("msg_user", index)
+  const assistantID = id("msg_assistant", index)
+  return [
+    {
+      info: {
+        id: userID,
+        sessionID,
+        role: "user",
+        time: { created: 1700000000000 + index * 10_000 },
+        summary: { diffs: [] },
+        agent: "build",
+        model,
+      },
+      parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
+    },
+    {
+      info: {
+        id: assistantID,
+        sessionID,
+        role: "assistant",
+        time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
+        parentID: userID,
+        modelID: model.modelID,
+        providerID: model.providerID,
+        mode: "build",
+        agent: "build",
+        path: { cwd: directory, root: directory },
+        cost: 0.01,
+        tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+        variant: "max",
+        finish: "stop",
+      },
+      parts: target
+        ? [
+            contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
+            contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
+            contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
+            contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
+            {
+              id: followingTextID,
+              sessionID,
+              messageID: assistantID,
+              type: "text",
+              text: "This assistant text is immediately after the explored context group.",
+            },
+          ]
+        : [
+            {
+              id: id("prt_text", index),
+              sessionID,
+              messageID: assistantID,
+              type: "text",
+              text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
+            },
+          ],
+    },
+  ]
+}
+
+function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
+  return {
+    id: partID,
+    sessionID,
+    messageID,
+    type: "tool",
+    callID: `call_${partID}`,
+    tool,
+    state: {
+      status: "completed",
+      input,
+      output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
+      title: input.filePath || input.path || input.pattern || "completed",
+      metadata: {},
+      time: { start: 1700000000000, end: 1700000000100 },
+    },
+  }
+}
+
+async function mockServer(page: Page) {
+  await page.route("**/*", async (route) => {
+    const url = new URL(route.request().url())
+    const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
+    if (url.port !== targetPort) return route.fallback()
+
+    const path = url.pathname
+    if (path === "/global/event" || path === "/event") return sse(route)
+    if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path)) return json(route, {})
+    if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, [])
+    if (path === "/provider") return json(route, provider())
+    if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
+    if (path === "/project") return json(route, [project()])
+    if (path === "/project/current") return json(route, project())
+    if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
+    if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    if (path === "/session") return json(route, [session()])
+    if (path === `/session/${sessionID}`) return json(route, session())
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
+    if (path === `/session/${sessionID}/message`) return json(route, messages)
+    return json(route, {})
+  })
+}
+
+async function settle(page: Page) {
+  await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
+}
+
+function id(prefix: string, index: number) {
+  return `${prefix}_${String(index).padStart(4, "0")}`
+}
+
+function project() {
+  return { id: projectID, worktree: directory, vcs: "git", name: "context-resize-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }
+}
+
+function session() {
+  return { id: sessionID, slug: "context-resize-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }
+}
+
+function provider() {
+  return {
+    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" },
+  }
+}
+
+function json(route: Route, body: unknown, headers?: Record<string, string>) {
+  return route.fulfill({
+    status: 200,
+    contentType: "application/json",
+    headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
+    body: JSON.stringify(body ?? null),
+  })
+}
+
+function sse(route: Route) {
+  return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
+}
+
+function base64Encode(value: string) {
+  return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}

+ 0 - 5
packages/app/src/pages/session/message-timeline.data.ts

@@ -23,7 +23,6 @@ export type TimelineRowMap = {
     userMessageID: string
     group: PartGroup
     previousAssistantPart: boolean
-    lastAssistantPart: boolean
   }
   Thinking: { userMessageID: string; reasoningHeading?: string }
   Retry: { userMessageID: string }
@@ -50,7 +49,6 @@ export namespace TimelineRow {
     userMessageID: string
     group: PartGroup
     previousAssistantPart: boolean
-    lastAssistantPart: boolean
   }> {}
   export class Thinking extends Data.TaggedClass("Thinking")<{
     userMessageID: string
@@ -151,8 +149,6 @@ export namespace Timeline {
             ),
           ]
         : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
-    const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
-
     if (comments.length > 0)
       rows.push(
         new TimelineRow.CommentStrip({
@@ -195,7 +191,6 @@ export namespace Timeline {
           userMessageID: userMessage.id,
           group: item.group,
           previousAssistantPart: assistantGroupIndex > 0,
-          lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
         }),
       )
       assistantGroupIndex += 1

+ 91 - 52
packages/app/src/pages/session/message-timeline.tsx

@@ -1,4 +1,16 @@
-import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Index,
+  on,
+  onCleanup,
+  Show,
+  mapArray,
+  type Accessor,
+  type JSX,
+} from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { useNavigate } from "@solidjs/router"
@@ -245,7 +257,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
 
   return (
     <div data-slot="session-turn-diff-view" data-scrollable>
-      <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
+      <Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
     </div>
   )
 }
@@ -415,8 +427,7 @@ export function MessageTimeline(props: {
     if (rows.length === 0) return rows
     return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
   })
-  const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
-  const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
+  const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
   const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
   const messageRowIndex = createMemo(() => {
     const result = new Map<string, number>()
@@ -427,6 +438,14 @@ export function MessageTimeline(props: {
     })
     return result
   })
+  const lastAssistantGroupKey = createMemo(() => {
+    const result = new Map<string, string>()
+    timelineRows().forEach((row) => {
+      if (row._tag !== "AssistantPart") return
+      result.set(row.userMessageID, row.group.key)
+    })
+    return result
+  })
   const keepMounted = createMemo(() => {
     const id = activeMessageID()
     if (!id) return
@@ -540,6 +559,7 @@ export function MessageTimeline(props: {
   const [bar, setBar] = createStore({
     ms: pace(640),
   })
+  const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
 
   let more: HTMLButtonElement | undefined
   let head: HTMLDivElement | undefined
@@ -560,6 +580,11 @@ export function MessageTimeline(props: {
 
   const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
 
+  const measureTimeline = () => {
+    virtualizer?.measure()
+    anchorMeasuredBottom()
+  }
+
   function anchorMeasuredBottom() {
     if (!listRoot) return false
     if (!measuredBottomAnchored) return false
@@ -980,29 +1005,40 @@ export function MessageTimeline(props: {
 
   const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
 
-  const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
-    if (row.group.type === "context") {
+  const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
+    if (row().group.type === "context") {
       const parts = createMemo(() => {
-        const group = row.group
+        const group = row().group
         if (group.type !== "context") return emptyTools
         return group.refs
           .map((ref) => getMsgPart(ref.messageID, ref.partID))
           .filter((part): part is ToolPart => part?.type === "tool")
       })
 
-      return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
+      return (
+        <ContextToolGroup
+          parts={parts()}
+          busy={workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key}
+          onSizeChange={measureTimeline}
+        />
+      )
     }
 
     const message = createMemo(() => {
-      const group = row.group
+      const group = row().group
       if (group.type !== "part") return
       return messageByID().get(group.ref.messageID)
     })
     const part = createMemo(() => {
-      const group = row.group
+      const group = row().group
       if (group.type !== "part") return
       return getMsgPart(group.ref.messageID, group.ref.partID)
     })
+    const defaultOpen = createMemo(() => {
+      const item = part()
+      if (!item) return
+      return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
+    })
 
     return (
       <Show when={message()}>
@@ -1012,14 +1048,13 @@ export function MessageTimeline(props: {
               <MessagePart
                 part={part()}
                 message={message()}
-                showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
-                turnDurationMs={turnDurationMs(row.userMessageID)}
-                defaultOpen={partDefaultOpen(
-                  part(),
-                  settings.general.shellToolPartsExpanded(),
-                  settings.general.editToolPartsExpanded(),
-                )}
+                showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
+                turnDurationMs={turnDurationMs(row().userMessageID)}
+                defaultOpen={defaultOpen()}
+                toolOpen={toolOpen[part().id] ?? defaultOpen()}
+                onToolOpenChange={(open) => setToolOpen(part().id, open)}
                 deferToolContent={false}
+                virtualizeDiff={false}
               />
             )}
           </Show>
@@ -1028,25 +1063,25 @@ export function MessageTimeline(props: {
     )
   }
 
-  function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
+  function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
     const anchor = () => {
-      const row = input.row
+      const row = input.row()
       return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
     }
     const previousUserMessage = () => {
-      const row = input.row
+      const row = input.row()
       return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
     }
     const previousAssistantPart = () => {
-      const row = input.row
+      const row = input.row()
       return row._tag === "AssistantPart" && row.previousAssistantPart
     }
 
     return (
       <div
-        id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
-        data-message-id={input.row.userMessageID}
-        data-timeline-row={input.row._tag}
+        id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
+        data-message-id={input.row().userMessageID}
+        data-timeline-row={input.row()._tag}
         classList={{
           "min-w-0 w-full max-w-full": true,
           "md:max-w-200 2xl:max-w-[1000px]": props.centered,
@@ -1062,14 +1097,15 @@ export function MessageTimeline(props: {
     )
   }
 
-  const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
-    switch (row._tag) {
+  const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
+    switch (row()._tag) {
       case "CommentStrip": {
+        const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
         const comments = createMemo(() =>
-          getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
+          getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
         )
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={commentStripRow}>
             <div class="w-full px-4 md:px-5 pb-2">
               <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
                 <div class="flex w-max min-w-full justify-end gap-2">
@@ -1102,17 +1138,18 @@ export function MessageTimeline(props: {
         )
       }
       case "UserMessage": {
+        const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
         const message = createMemo(() => {
-          const m = messageByID().get(row.userMessageID)
+          const m = messageByID().get(userMessageRow().userMessageID)
           if (m?.role === "user") return m
         })
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={userMessageRow}>
             <Show when={message()}>
               {(message) => (
                 <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
                   <div data-slot="session-turn-message-content" aria-live="off">
-                    <Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
+                    <Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
                   </div>
                 </div>
               )}
@@ -1121,13 +1158,14 @@ export function MessageTimeline(props: {
         )
       }
       case "TurnDivider": {
+        const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={turnDividerRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
               <div data-slot="session-turn-compaction">
                 <MessageDivider
                   label={language.t(
-                    row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
+                    turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
                   )}
                 />
               </div>
@@ -1136,22 +1174,24 @@ export function MessageTimeline(props: {
         )
       }
       case "AssistantPart": {
+        const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={assistantPartRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
-              <div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
-                {renderAssistantPartGroup(row)}
+              <div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
+                {renderAssistantPartGroup(assistantPartRow)}
               </div>
             </div>
           </TimelineRowFrame>
         )
       }
       case "Thinking": {
+        const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={thinkingRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
               <TimelineThinkingRow
-                reasoningHeading={row.reasoningHeading}
+                reasoningHeading={thinkingRow().reasoningHeading}
                 showReasoningSummaries={settings.general.showReasoningSummaries()}
               />
             </div>
@@ -1159,29 +1199,32 @@ export function MessageTimeline(props: {
         )
       }
       case "Retry": {
+        const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={retryRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
-              <SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
+              <SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
             </div>
           </TimelineRowFrame>
         )
       }
       case "DiffSummary": {
+        const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={diffSummaryRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
-              <TimelineDiffSummaryRow diffs={row.diffs} />
+              <TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
             </div>
           </TimelineRowFrame>
         )
       }
       case "Error": {
+        const errorRow = row as Accessor<TimelineRowByTag<"Error">>
         return (
-          <TimelineRowFrame row={row}>
+          <TimelineRowFrame row={errorRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
               <Card variant="error" class="error-card">
-                {row.text}
+                {errorRow().text}
               </Card>
             </div>
           </TimelineRowFrame>
@@ -1192,12 +1235,8 @@ export function MessageTimeline(props: {
     }
   }
 
-  function TimelineRowView(props: { rowKey: string }) {
-    return (
-      <Show when={timelineRowByKey().get(props.rowKey)} keyed>
-        {(item) => renderTimelineRow(item)}
-      </Show>
-    )
+  function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
+    return renderTimelineRow(() => props.row)
   }
 
   return (
@@ -1529,7 +1568,7 @@ export function MessageTimeline(props: {
         <Show when={scrollRoot()}>
           {(root) => (
             <Virtualizer
-              data={timelineRowKeys()}
+              data={timelineRows()}
               cache={virtualCache()}
               itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
               scrollRef={root()}
@@ -1549,7 +1588,7 @@ export function MessageTimeline(props: {
                 scheduleContentRoot(root())
               }}
             >
-              {(key) => <TimelineRowView rowKey={key} />}
+              {(row) => <TimelineRowView row={row} />}
             </Virtualizer>
           )}
         </Show>

+ 0 - 1
packages/ui/src/components/apply-patch-file.test.ts

@@ -36,7 +36,6 @@ describe("apply patch file", () => {
     ])[0]
 
     expect(file).toBeDefined()
-    expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
     expect(text(file!.view, "deletions")).toBe("one\n")
     expect(text(file!.view, "additions")).toBe("two\n")
   })

+ 12 - 3
packages/ui/src/components/basic-tool.tsx

@@ -29,6 +29,8 @@ export interface BasicToolProps {
   status?: string
   hideDetails?: boolean
   defaultOpen?: boolean
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
   forceOpen?: boolean
   defer?: boolean
   locked?: boolean
@@ -83,7 +85,7 @@ export function BasicTool(props: BasicToolProps) {
     open: props.defaultOpen ?? false,
     ready: !props.defer && (props.defaultOpen ?? false),
   })
-  const open = () => state.open
+  const open = () => props.open ?? state.open
   const ready = () => state.ready
   const pending = () => props.status === "pending" || props.status === "running"
   const hasChildren = () => (props.defer ? "children" in props : props.children)
@@ -110,8 +112,15 @@ export function BasicTool(props: BasicToolProps) {
     if (props.defer && open()) scheduleReady(true)
   })
 
+  const setOpen = (value: boolean) => {
+    if (props.open === undefined) setState("open", value)
+    props.onOpenChange?.(value)
+  }
+
   createEffect(() => {
-    if (props.forceOpen) setState("open", true)
+    if (!props.forceOpen) return
+    if (open()) return
+    setOpen(true)
   })
 
   createEffect(
@@ -166,7 +175,7 @@ export function BasicTool(props: BasicToolProps) {
   const handleOpenChange = (value: boolean) => {
     if (pending()) return
     if (props.locked && !value) return
-    setState("open", value)
+    setOpen(value)
   }
 
   const trigger = () => (

+ 72 - 13
packages/ui/src/components/file.tsx

@@ -1,5 +1,7 @@
 import { sampledChecksum } from "@opencode-ai/core/util/encode"
 import {
+  areFilesEqual,
+  areOptionsEqual,
   DEFAULT_VIRTUAL_FILE_METRICS,
   type DiffLineAnnotation,
   type FileContents,
@@ -88,6 +90,7 @@ type DiffBaseProps<T> = FileDiffOptions<T> &
     mode: "diff"
     annotations?: DiffLineAnnotation<T>[]
     preloadedDiff?: DiffPreload<T>
+    virtualize?: boolean
   }
 
 type DiffPairProps<T> = DiffBaseProps<T> & {
@@ -123,7 +126,7 @@ const sharedKeys = [
 ] as const
 
 const textKeys = ["file", ...sharedKeys] as const
-const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
+const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
 
 // ---------------------------------------------------------------------------
 // Shared viewer hook
@@ -482,17 +485,24 @@ function notifyRendered(opts: {
 function renderViewer<I extends RenderTarget>(opts: {
   viewer: Viewer
   current: I | undefined
+  reset?: boolean
   create: () => I
+  update?: (value: I) => void
   assign: (value: I) => void
   draw: (value: I) => void
   onReady: () => void
 }) {
   clearReadyWatcher(opts.viewer.ready)
-  opts.current?.cleanUp()
-  const next = opts.create()
-  opts.assign(next)
+  const reset = opts.reset === true && opts.current !== undefined
+  if (reset) opts.current?.cleanUp()
+  const next = reset || !opts.current ? opts.create() : opts.current
+  if (reset || !opts.current) {
+    opts.viewer.container.innerHTML = ""
+    opts.assign(next)
+  } else {
+    opts.update?.(next)
+  }
 
-  opts.viewer.container.innerHTML = ""
   opts.draw(next)
 
   applyViewerScheme(opts.viewer.getHost())
@@ -566,7 +576,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab
   }
 }
 
-function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
+function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
   let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
 
   const release = () => {
@@ -576,6 +586,10 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): Vi
 
   return {
     get: () => {
+      if (!enabled()) {
+        release()
+        return
+      }
       if (shared) return shared.virtualizer
 
       const container = host()
@@ -858,15 +872,14 @@ function TextViewer<T>(props: TextFileProps<T>) {
   createEffect(() => {
     const opts = options()
     const workerPool = getWorkerPool("unified")
-    const isVirtual = virtual()
-
     const virtualizer = virtuals.get()
 
     renderViewer({
       viewer,
       current: instance,
+      reset: instance !== undefined,
       create: () =>
-        isVirtual && virtualizer
+        virtualizer
           ? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
           : new PierreFile<T>(opts, workerPool),
       assign: (value) => {
@@ -907,6 +920,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
 
 function DiffViewer<T>(props: DiffFileProps<T>) {
   let instance: FileDiff<T> | undefined
+  let instanceVirtualizer: Virtualizer | undefined
+  let instanceWorkerPool: ReturnType<typeof getWorkerPool>
+  let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
+  let instanceFileDiff: FileDiffMetadata | undefined
+  let instanceBefore: FileContents | undefined
+  let instanceAfter: FileContents | undefined
   let dragSide: DiffSelectionSide | undefined
   let dragEndSide: DiffSelectionSide | undefined
   let viewer!: Viewer
@@ -991,7 +1010,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
     adapter,
   )
 
-  const virtuals = createSharedVirtualStrategy(() => viewer.container)
+  const virtuals = createSharedVirtualStrategy(
+    () => viewer.container,
+    () => local.virtualize !== false,
+  )
 
   const large = createMemo(() => {
     if (local.fileDiff) {
@@ -1067,31 +1089,62 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
       return sampledChecksum(contents)
     }
 
+    const before = local.before ? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) } : undefined
+    const after = local.after ? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) } : undefined
+    const targetChanged =
+      local.fileDiff !== undefined
+        ? instanceFileDiff !== local.fileDiff
+        : instanceFileDiff !== undefined ||
+          before === undefined ||
+          after === undefined ||
+          instanceBefore === undefined ||
+          instanceAfter === undefined ||
+          !areFilesEqual(instanceBefore, before) ||
+          !areFilesEqual(instanceAfter, after)
+    // Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
+    // Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
+    const reset =
+      instance !== undefined &&
+      (instanceVirtualizer !== virtualizer ||
+        instanceWorkerPool !== workerPool ||
+        (virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
+    const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
+
     renderViewer({
       viewer,
       current: instance,
+      reset,
       create: () =>
         virtualizer
           ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
           : new FileDiff<T>(opts, workerPool),
+      update: (value) => value.setOptions(opts),
       assign: (value) => {
         instance = value
+        instanceVirtualizer = virtualizer
+        instanceWorkerPool = workerPool
+        instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
+        instanceFileDiff = local.fileDiff
+        instanceBefore = before
+        instanceAfter = after
       },
       draw: (value) => {
         if (local.fileDiff) {
           value.render({
             fileDiff: local.fileDiff,
+            forceRender,
             lineAnnotations: [],
             containerWrapper: viewer.container,
           })
           return
         }
 
-        if (!local.before || !local.after) return
+        if (!before || !after) return
 
         value.render({
-          oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
-          newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
+          oldFile: before,
+          newFile: after,
+          forceRender,
           lineAnnotations: [],
           containerWrapper: viewer.container,
         })
@@ -1111,6 +1164,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
   onCleanup(() => {
     instance?.cleanUp()
     instance = undefined
+    instanceVirtualizer = undefined
+    instanceWorkerPool = undefined
+    instanceVirtualHunkSeparators = undefined
+    instanceFileDiff = undefined
+    instanceBefore = undefined
+    instanceAfter = undefined
     virtuals.cleanup()
     dragSide = undefined
     dragEndSide = undefined

+ 46 - 11
packages/ui/src/components/message-part.tsx

@@ -175,7 +175,10 @@ export interface MessagePartProps {
   message: MessageType
   hideDetails?: boolean
   defaultOpen?: boolean
+  toolOpen?: boolean
+  onToolOpenChange?: (open: boolean) => void
   deferToolContent?: boolean
+  virtualizeDiff?: boolean
   showAssistantCopyPartID?: string | null
   turnDurationMs?: number
 }
@@ -290,7 +293,7 @@ function getDirectory(path: string | undefined) {
 }
 
 import type { IconProps } from "./icon"
-import { normalize } from "./session-diff"
+import { normalize, resolveFileDiff } from "./session-diff"
 
 export type ToolInfo = {
   icon: IconProps["name"]
@@ -930,7 +933,7 @@ export function AssistantMessageDisplay(props: {
   )
 }
 
-export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
+export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
   const i18n = useI18n()
   const [open, setOpen] = createSignal(false)
   const pending = createMemo(
@@ -938,11 +941,15 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
       !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
   )
   const summary = createMemo(() => contextToolSummary(props.parts))
+  const handleOpenChange = (value: boolean) => {
+    setOpen(value)
+    props.onSizeChange?.()
+  }
 
   return (
     <Collapsible
       open={open()}
-      onOpenChange={setOpen}
+      onOpenChange={handleOpenChange}
       variant="ghost"
       class="tool-collapsible"
       data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
@@ -1261,7 +1268,10 @@ export function Part(props: MessagePartProps) {
         message={props.message}
         hideDetails={props.hideDetails}
         defaultOpen={props.defaultOpen}
+        toolOpen={props.toolOpen}
+        onToolOpenChange={props.onToolOpenChange}
         deferToolContent={props.deferToolContent}
+        virtualizeDiff={props.virtualizeDiff}
         showAssistantCopyPartID={props.showAssistantCopyPartID}
         turnDurationMs={props.turnDurationMs}
       />
@@ -1278,7 +1288,10 @@ export interface ToolProps {
   status?: string
   hideDetails?: boolean
   defaultOpen?: boolean
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
   deferContent?: boolean
+  virtualizeDiff?: boolean
   forceOpen?: boolean
   locked?: boolean
 }
@@ -1376,6 +1389,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
   })
 
   const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
+  const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
+  const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open)
 
   return (
     <Show when={!hideQuestion()}>
@@ -1399,6 +1414,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
                   error={error()}
                   title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
                   defaultOpen={props.defaultOpen}
+                  open={controlledOpen()}
+                  onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
                   subtitle={taskSubtitle()}
                   href={taskHref()}
                 />
@@ -1417,7 +1434,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
               status={part().state.status}
               hideDetails={props.hideDetails}
               defaultOpen={props.defaultOpen}
+              open={controlledOpen()}
+              onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
               deferContent={props.deferToolContent}
+              virtualizeDiff={props.virtualizeDiff}
             />
           </Match>
         </Switch>
@@ -1921,15 +1941,29 @@ ToolRegistry.register({
     const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
     const filename = () => getFilename(props.input.filePath ?? "")
     const pending = () => props.status === "pending" || props.status === "running"
+    const diffSource = createMemo(
+      () => {
+        const filediff = props.metadata?.filediff
+        if (!filediff) return
+        return {
+          file: filediff.file || props.input.filePath || "",
+          patch: typeof filediff.patch === "string" ? filediff.patch : undefined,
+          before: typeof filediff.before === "string" ? filediff.before : undefined,
+          after: typeof filediff.after === "string" ? filediff.after : undefined,
+        }
+      },
+      undefined,
+      {
+        equals: (a, b) =>
+          a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after,
+      },
+    )
 
     const fileCompProps = createMemo(() => {
       try {
-        if (props.metadata?.filediff) {
-          const diff = normalize({
-            ...props.metadata?.filediff,
-            status: "modified",
-          })
-          const fileDiff = diff.fileDiff
+        const source = diffSource()
+        if (source) {
+          const fileDiff = resolveFileDiff(source)
           if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
         }
       } catch {}
@@ -1987,7 +2021,7 @@ ToolRegistry.register({
               }
             >
               <div data-component="edit-content">
-                <Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
+                <Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
               </div>
             </ToolFileAccordion>
           </Show>
@@ -2171,6 +2205,7 @@ ToolRegistry.register({
                                 <Dynamic
                                   component={fileComponent}
                                   mode="diff"
+                                  virtualize={props.virtualizeDiff}
                                   fileDiff={file.view.fileDiff}
                                   hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
                                 />
@@ -2243,7 +2278,7 @@ ToolRegistry.register({
               }
             >
               <div data-component="apply-patch-file-diff">
-                <Dynamic component={fileComponent} mode="diff" fileDiff={single()!.view.fileDiff} />
+                <Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} />
               </div>
             </ToolFileAccordion>
           </BasicTool>

+ 49 - 5
packages/ui/src/components/session-diff.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { normalize, text } from "./session-diff"
+import { normalize, resolveFileDiff, text } from "./session-diff"
 
 describe("session diff", () => {
   test("keeps unified patch content", () => {
@@ -13,8 +13,8 @@ describe("session diff", () => {
     }
     const view = normalize(diff)
 
-    expect(view.patch).toBe(diff.patch)
     expect(view.fileDiff.name).toBe("a.ts")
+    expect(view.fileDiff.isPartial).toBe(true)
     expect(text(view, "deletions")).toBe("one\ntwo\n")
     expect(text(view, "additions")).toBe("one\nthree\n")
   })
@@ -34,7 +34,52 @@ describe("session diff", () => {
     expect(text(view, "additions")).toBe("one\nthree")
   })
 
-  test("converts legacy content into a patch", () => {
+  test("keeps separated patch hunks partial without complete file contents", () => {
+    const fileDiff = resolveFileDiff({
+      file: "project.ts",
+      patch:
+        'Index: project.ts\n===================================================================\n--- project.ts\t\n+++ project.ts\t\n@@ -1,3 +1,2 @@\n import { and } from "drizzle-orm"\n-import { sql } from "drizzle-orm"\n import { ProjectTable } from "./project.sql"\n@@ -346,3 +345,3 @@\n import { Database } from "@/storage/db"\n-import { ProjectTable } from "./project.sql"\n+import { ProjectTable } from "../project/project.sql"\n import { SessionTable } from "../session/session.sql"\n',
+    })
+
+    expect(fileDiff.isPartial).toBe(true)
+    expect(fileDiff.hunks).toHaveLength(2)
+    expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0)
+  })
+
+  test("renders headerless persisted patches", () => {
+    const view = normalize({
+      file: "a.ts",
+      patch: "@@ -1 +1 @@\n-old\n+new\n",
+      additions: 1,
+      deletions: 1,
+      status: "modified" as const,
+    })
+
+    expect(view.fileDiff.name).toBe("a.ts")
+    expect(view.fileDiff.isPartial).toBe(true)
+    expect(text(view, "deletions")).toBe("old\n")
+    expect(text(view, "additions")).toBe("new\n")
+  })
+
+  test("does not share headerless patch metadata between files", () => {
+    const patch = "@@ -1 +1 @@\n-old\n+new\n"
+
+    expect(resolveFileDiff({ file: "a.ts", patch }).name).toBe("a.ts")
+    expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
+  })
+
+  test("keeps capped header-only patches partial", () => {
+    const fileDiff = resolveFileDiff({
+      file: "a.ts",
+      patch: "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n",
+    })
+
+    expect(fileDiff.name).toBe("a.ts")
+    expect(fileDiff.isPartial).toBe(true)
+    expect(fileDiff.hunks).toEqual([])
+  })
+
+  test("keeps full legacy content as a complete diff", () => {
     const diff = {
       file: "a.ts",
       before: "one\n",
@@ -45,7 +90,7 @@ describe("session diff", () => {
     }
     const view = normalize(diff)
 
-    expect(view.patch).toContain("@@ -1,1 +1,1 @@")
+    expect(view.fileDiff.isPartial).toBe(false)
     expect(text(view, "deletions")).toBe("one\n")
     expect(text(view, "additions")).toBe("two\n")
   })
@@ -61,7 +106,6 @@ describe("session diff", () => {
     }
     const view = normalize(diff)
 
-    expect(view.patch).toBe(diff.patch)
     expect(text(view, "deletions")).toBe("")
     expect(text(view, "additions")).toBe("")
   })

+ 49 - 84
packages/ui/src/components/session-diff.ts

@@ -1,5 +1,5 @@
 import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
-import { formatPatch, parsePatch, structuredPatch } from "diff"
+import { parsePatch } from "diff"
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
 
 type LegacyDiff = {
@@ -14,107 +14,35 @@ type LegacyDiff = {
 
 type SnapshotDiff = SnapshotFileDiff & { file: string }
 type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
+export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
 
 export type ViewDiff = {
   file: string
-  patch: string
   additions: number
   deletions: number
   status?: "added" | "deleted" | "modified"
   fileDiff: FileDiffMetadata
 }
 
-const cache = new Map<string, FileDiffMetadata>()
+const diffCacheLimit = 16
+const patchFileDiffCache = new Map<string, FileDiffMetadata>()
 
-function patch(diff: ReviewDiff) {
-  if (typeof diff.patch === "string") {
-    try {
-      const [patch] = parsePatch(diff.patch)
-      const beforeLines: Array<{ text: string; newline: boolean }> = []
-      const afterLines: Array<{ text: string; newline: boolean }> = []
-      let previous: "-" | "+" | " " | undefined
-
-      const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1)
-
-      for (const hunk of patch.hunks) {
-        for (const line of hunk.lines) {
-          if (line.startsWith("\\")) {
-            if (previous === "-" || previous === " ") {
-              const before = beforeLines.at(-1)
-              if (before) before.newline = false
-            }
-            if (previous === "+" || previous === " ") {
-              const after = afterLines.at(-1)
-              if (after) after.newline = false
-            }
-            continue
-          }
-
-          if (line.startsWith("-")) {
-            beforeLines.push({ text: line.slice(1), newline: true })
-            previous = "-"
-          } else if (line.startsWith("+")) {
-            afterLines.push({ text: line.slice(1), newline: true })
-            previous = "+"
-          } else {
-            // context line (starts with ' ')
-            beforeLines.push({ text: line.slice(1), newline: true })
-            afterLines.push({ text: line.slice(1), newline: true })
-            previous = " "
-          }
-        }
-      }
-
-      return {
-        before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
-        after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
-        patch: diff.patch,
-        patchIsPartial,
-      }
-    } catch {
-      return { before: "", after: "", patch: diff.patch, patchIsPartial: false }
-    }
-  }
-  return {
-    before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
-    after: "after" in diff && typeof diff.after === "string" ? diff.after : "",
-    patch: formatPatch(
-      structuredPatch(
-        diff.file,
-        diff.file,
-        "before" in diff && typeof diff.before === "string" ? diff.before : "",
-        "after" in diff && typeof diff.after === "string" ? diff.after : "",
-        "",
-        "",
-        { context: Number.MAX_SAFE_INTEGER },
-      ),
-    ),
-    patchIsPartial: false,
-  }
-}
-
-function file(file: string, patch: string, before: string, after: string, partial = false) {
-  const hit = cache.get(patch)
-  if (hit) return hit
-
-  let value: FileDiffMetadata | undefined
-  if (partial) value = parsePatchFiles(patch)[0]?.files[0]
-  if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
-
-  cache.set(patch, value)
-  return value
+export function resolveFileDiff(diff: DiffSource) {
+  if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
+  return fileDiffFromContent(
+    diff.file,
+    typeof diff.before === "string" ? diff.before : "",
+    typeof diff.after === "string" ? diff.after : "",
+  )
 }
 
 export function normalize(diff: ReviewDiff): ViewDiff {
-  const next = patch(diff)
-  const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial)
   return {
     file: diff.file,
-    patch: next.patch,
     additions: diff.additions,
     deletions: diff.deletions,
     status: diff.status,
-    fileDiff,
+    fileDiff: resolveFileDiff(diff),
   }
 }
 
@@ -122,3 +50,40 @@ export function text(diff: ViewDiff, side: "deletions" | "additions") {
   if (side === "deletions") return diff.fileDiff.deletionLines.join("")
   return diff.fileDiff.additionLines.join("")
 }
+
+function fileDiffFromPatch(file: string, patch: string) {
+  const key = `${file}\0${patch}`
+  const hit = patchFileDiffCache.get(key)
+  if (hit) {
+    patchFileDiffCache.delete(key)
+    patchFileDiffCache.set(key, hit)
+    return hit
+  }
+
+  const input = patchInput(file, patch)
+  const value = (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file)
+  patchFileDiffCache.set(key, value)
+  while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
+  return value
+}
+
+function patchInput(file: string, patch: string) {
+  try {
+    const parsed = parsePatch(patch)[0]
+    if (!parsed) return
+    if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
+    if (!parsed.hunks.length) return
+    return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
+  } catch {
+    return
+  }
+}
+
+function fileDiffFromContent(file: string, before: string, after: string) {
+  if (!before && !after) return emptyFileDiff(file)
+  return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
+}
+
+function emptyFileDiff(file: string) {
+  return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
+}

+ 18 - 3
packages/ui/src/components/tool-error-card.tsx

@@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
   error: string
   title?: string
   defaultOpen?: boolean
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
   subtitle?: string
   href?: string
 }
@@ -22,9 +24,22 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
     open: props.defaultOpen ?? false,
     copied: false,
   })
-  const open = () => state.open
+  const open = () => props.open ?? state.open
   const copied = () => state.copied
-  const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"])
+  const [split, rest] = splitProps(props, [
+    "tool",
+    "error",
+    "title",
+    "defaultOpen",
+    "open",
+    "onOpenChange",
+    "subtitle",
+    "href",
+  ])
+  const setOpen = (value: boolean) => {
+    if (props.open === undefined) setState("open", value)
+    props.onOpenChange?.(value)
+  }
   const name = createMemo(() => {
     if (split.title) return split.title
     const map: Record<string, string> = {
@@ -81,7 +96,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
         class="tool-collapsible"
         data-open={open() ? "true" : "false"}
         open={open()}
-        onOpenChange={(value) => setState("open", value)}
+        onOpenChange={setOpen}
       >
         <Collapsible.Trigger>
           <div data-component="tool-trigger">

+ 93 - 0
patches/virtua@0.49.1.patch

@@ -0,0 +1,93 @@
+diff --git a/lib/solid/Virtualizer.d.ts b/lib/solid/Virtualizer.d.ts
+index 144dd7f..819aab9 100644
+--- a/lib/solid/Virtualizer.d.ts
++++ b/lib/solid/Virtualizer.d.ts
+@@ -38,6 +38,10 @@ export interface VirtualizerHandle {
+      * @param index index of item
+      */
+     getItemSize(index: number): number;
++    /**
++     * Synchronously measure currently mounted items and update cached item sizes.
++     */
++    measure(): void;
+     /**
+      * Scroll to the item specified by index.
+      * @param index index of item
+diff --git a/lib/solid/index.jsx b/lib/solid/index.jsx
+index 029201a..3949cd4 100644
+--- a/lib/solid/index.jsx
++++ b/lib/solid/index.jsx
+@@ -1085,6 +1085,7 @@ const createResizer = (store, isHorizontal) => {
+     let viewportElement;
+     const sizeKey = isHorizontal ? "width" : "height";
+     const mountedIndexes = new WeakMap();
++    const mountedItems = new Map();
+     const resizeObserver = createResizeObserver((entries) => {
+         const resizes = [];
+         for (const { target, contentRect } of entries) {
+@@ -1111,12 +1112,27 @@ const createResizer = (store, isHorizontal) => {
+         },
+         $observeItem: (el, i) => {
+             mountedIndexes.set(el, i);
++            mountedItems.set(i, el);
+             resizeObserver._observe(el);
+             return () => {
+                 mountedIndexes.delete(el);
++                if (mountedItems.get(i) === el) {
++                    mountedItems.delete(i);
++                }
+                 resizeObserver._unobserve(el);
+             };
+         },
++        $measureItems: () => {
++            const resizes = [];
++            mountedItems.forEach((el, index) => {
++                if (!el.offsetParent)
++                    return;
++                resizes.push([index, el.getBoundingClientRect()[sizeKey]]);
++            });
++            if (resizes.length) {
++                store.$update(ACTION_ITEM_RESIZE, resizes);
++            }
++        },
+         $dispose: resizeObserver._dispose,
+     };
+ };
+@@ -1354,6 +1370,8 @@ const Virtualizer = (props) => {
+     const range = createMemo((prev) => {
+         stateVersion();
+         const next = store.$getRange(props.bufferSize);
++        next[0] = Math.max(0, next[0]);
++        next[1] = Math.min(props.data.length - 1, next[1]);
+         if (prev && isSameRange(prev, next)) {
+             return prev;
+         }
+@@ -1380,6 +1398,7 @@ const Virtualizer = (props) => {
+                 findItemIndex: store.$findItemIndex,
+                 getItemOffset: store.$getItemOffset,
+                 getItemSize: store.$getItemSize,
++                measure: resizer.$measureItems,
+                 scrollToIndex: scroller.$scrollToIndex,
+                 scrollTo: scroller.$scrollTo,
+                 scrollBy: scroller.$scrollBy,
+@@ -1417,6 +1436,11 @@ const Virtualizer = (props) => {
+         const indexes = [];
+         if (props.keepMounted) {
+             const mounted = new Set(props.keepMounted);
++            mounted.forEach((index) => {
++                if (index < 0 || index >= count) {
++                    mounted.delete(index);
++                }
++            });
+             for (let [i, j] = range(); i <= j; i++) {
+                 mounted.add(i);
+             }
+@@ -1528,6 +1552,8 @@ const WindowVirtualizer = (props) => {
+     const range = createMemo((prev) => {
+         stateVersion();
+         const next = store.$getRange(props.bufferSize);
++        next[0] = Math.max(0, next[0]);
++        next[1] = Math.min(props.data.length - 1, next[1]);
+         if (prev && isSameRange(prev, next)) {
+             return prev;
+         }