Просмотр исходного кода

fix(app): stabilize session timeline layout continuity (#34533)

Luke Parker 1 месяц назад
Родитель
Сommit
3cf71808c4
57 измененных файлов с 6159 добавлено и 142 удалено
  1. 1 0
      bun.lock
  2. 61 0
      packages/app/e2e/performance/timeline-stability/README.md
  3. 250 0
      packages/app/e2e/performance/timeline-stability/adverse.spec.ts
  4. 192 0
      packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts
  5. 113 0
      packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts
  6. 121 0
      packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts
  7. 113 0
      packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts
  8. 68 0
      packages/app/e2e/performance/timeline-stability/fixture.test.ts
  9. 561 0
      packages/app/e2e/performance/timeline-stability/fixture.ts
  10. 242 0
      packages/app/e2e/performance/timeline-stability/interaction.spec.ts
  11. 157 0
      packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts
  12. 75 0
      packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts
  13. 17 0
      packages/app/e2e/performance/timeline-stability/playwright.config.ts
  14. 319 0
      packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts
  15. 255 0
      packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts
  16. 106 0
      packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts
  17. 198 0
      packages/app/e2e/performance/timeline-stability/tools.spec.ts
  18. 272 0
      packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts
  19. 392 0
      packages/app/e2e/performance/unit/visual-stability.test.ts
  20. 22 0
      packages/app/e2e/regression/session-timeline-accessibility.spec.ts
  21. 116 9
      packages/app/e2e/regression/session-timeline-context-resize.spec.ts
  22. 31 0
      packages/app/e2e/regression/session-timeline-context-state.spec.ts
  23. 52 0
      packages/app/e2e/regression/session-timeline-file-projection.spec.ts
  24. 94 0
      packages/app/e2e/regression/session-timeline-file-state.spec.ts
  25. 94 0
      packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
  26. 25 0
      packages/app/e2e/regression/session-timeline-locale-projection.spec.ts
  27. 284 0
      packages/app/e2e/regression/session-timeline-projection.spec.ts
  28. 93 0
      packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
  29. 43 0
      packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
  30. 99 0
      packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
  31. 77 0
      packages/app/e2e/regression/session-timeline-tool-state.spec.ts
  32. 116 0
      packages/app/e2e/regression/session-timeline-transport.spec.ts
  33. 8 1
      packages/app/e2e/tsconfig.json
  34. 3 1
      packages/app/e2e/utils/mock-server.ts
  35. 284 0
      packages/app/e2e/utils/sse-transport.ts
  36. 54 0
      packages/app/e2e/utils/visual-stability.ts
  37. 209 0
      packages/app/e2e/utils/visual-stability/analyzer.ts
  38. 51 0
      packages/app/e2e/utils/visual-stability/capture.ts
  39. 8 0
      packages/app/e2e/utils/visual-stability/index.ts
  40. 112 0
      packages/app/e2e/utils/visual-stability/invariant.ts
  41. 47 0
      packages/app/e2e/utils/visual-stability/model.ts
  42. 226 0
      packages/app/e2e/utils/visual-stability/probe.ts
  43. 18 0
      packages/app/e2e/utils/visual-stability/regions.ts
  44. 63 0
      packages/app/e2e/utils/visual-stability/reporter.ts
  45. 20 0
      packages/app/e2e/utils/visual-stability/scenario.ts
  46. 3 0
      packages/app/package.json
  47. 6 3
      packages/app/src/pages/session.tsx
  48. 35 17
      packages/app/src/pages/session/timeline/message-timeline.tsx
  49. 96 0
      packages/app/src/pages/session/timeline/projection.test.ts
  50. 3 12
      packages/app/src/pages/session/timeline/projection.ts
  51. 56 0
      packages/app/src/pages/session/timeline/row-reconciliation.ts
  52. 3 78
      packages/app/src/pages/session/timeline/rows.ts
  53. 80 0
      packages/app/src/pages/session/timeline/timeline-row.ts
  54. 14 11
      packages/session-ui/src/components/basic-tool.tsx
  55. 47 7
      packages/session-ui/src/components/message-part.tsx
  56. 22 1
      packages/ui/src/components/scroll-view.test.ts
  57. 32 2
      packages/ui/src/components/scroll-view.tsx

+ 1 - 0
bun.lock

@@ -37,6 +37,7 @@
         "@dnd-kit/solid": "0.5.0",
         "@dnd-kit/solid": "0.5.0",
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
+        "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/session-ui": "workspace:*",
         "@opencode-ai/session-ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",

+ 61 - 0
packages/app/e2e/performance/timeline-stability/README.md

@@ -0,0 +1,61 @@
+# Timeline Layout Continuity
+
+Run from `packages/app`:
+
+```sh
+bun run test:stability
+```
+
+The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device.
+
+## What It Proves
+
+The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
+
+- Preserve a visible semantic anchor while the user is away from the bottom.
+- Preserve end anchoring while active content grows or new content appears.
+- Keep adjacent visible rows ordered without material overlap.
+- Keep user-selected disclosure state through updates and virtualization.
+- Avoid a sampled blank interval while one visible surface replaces another.
+- Preserve logical row and control identity where local state or focus depends on it.
+- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
+
+The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
+
+## What It Does Not Prove
+
+The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled.
+
+The suite does not provide complete coverage for:
+
+- Compositor-only or raster-only glitches.
+- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
+- Physical display refresh rates, native OS scaling, or a named low-end device.
+- TCP packetization, proxy buffering, or the complete real server/provider pipeline.
+
+Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
+
+For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
+
+## Test Layers
+
+- **Projection:** admitted rows, grouping, labels, and final visible states.
+- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
+- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
+- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
+- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
+- **Oracle contract:** pure analyzer and browser sampler calibration tests.
+
+Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior.
+
+## Diagnostics
+
+Failures retain:
+
+- `video.webm`
+- `trace.zip`
+- failure screenshot
+- sampled DOM/layout trace JSON
+- event markers and summarized violations
+
+The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does.

+ 250 - 0
packages/app/e2e/performance/timeline-stability/adverse.spec.ts

@@ -0,0 +1,250 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  shell,
+  textPart,
+  toolPart,
+  userMessage,
+  waitForVisualSettle,
+  type TimelineMessage,
+} from "./fixture"
+
+test.describe("timeline adverse visual stability", () => {
+  test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
+    const activeShellID = "prt_adverse_01_shell"
+    const messages = [
+      ...history(24),
+      userMessage(),
+      assistantMessage([shell(activeShellID, "running")], { completed: false }),
+    ]
+    const timeline = await setupTimeline(page, {
+      messages,
+      settings: { shellToolPartsExpanded: true },
+      cpuRate: 4,
+      eventRetry: 30,
+    })
+    const scroller = page.locator(".scroll-view__viewport", {
+      has: page.locator('[data-timeline-row="AssistantPart"]'),
+    })
+    await scroller.evaluate((element) => {
+      element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
+      element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
+    })
+    await page.waitForTimeout(150)
+    await expect
+      .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
+      .toBeGreaterThan(100)
+    const anchor = await scroller.evaluate((element) => {
+      const view = element.getBoundingClientRect()
+      return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
+        const rect = row.getBoundingClientRect()
+        return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
+      })?.dataset.timelineKey
+    })
+    expect(anchor).toBeTruthy()
+    await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
+
+    const regions = defineVisualRegions({
+      anchor: { selector: `[data-timeline-key="${anchor}"]` },
+    })
+    await startVisualProbe(page, regions)
+    await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
+    await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
+    await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
+    await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "scrolled-away-shell",
+      trace,
+      visualPlan(regions, [
+        { type: "required", regions: ["anchor"] },
+        { type: "unique", regions: ["anchor"] },
+        { type: "stable", regions: ["anchor"] },
+        { type: "fixed", regions: ["anchor"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+      ]),
+    )
+  })
+
+  test("preserves an explicit shell state across virtualization", async ({ page }) => {
+    const targetID = "prt_virtual_shell"
+    const messages = [
+      userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
+      assistantMessage([shell(targetID, "completed", lines(20))], {
+        id: "msg_0001_virtual_assistant",
+        parentID: "msg_0000_virtual_user",
+        created: 1700000001000,
+      }),
+      ...history(35, 10),
+    ]
+    await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
+    const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+    await scroller.evaluate((element) => {
+      element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
+      element.scrollTop = 0
+    })
+    await page.waitForTimeout(300)
+    const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
+    await expect(trigger).toBeVisible()
+    await trigger.click()
+    await expect(trigger).toHaveAttribute("aria-expanded", "true")
+
+    await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
+    await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
+    await scroller.evaluate((element) => (element.scrollTop = 0))
+    await expect(trigger).toBeVisible()
+    await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  })
+
+  test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
+    const shellID = "prt_narrow_01_shell"
+    const followingID = "prt_narrow_02_following"
+    const timeline = await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage(
+          [shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
+          {
+            completed: false,
+          },
+        ),
+      ],
+      settings: { shellToolPartsExpanded: true },
+      viewport: { width: 430, height: 800 },
+      cpuRate: 4,
+    })
+    await waitForVisualSettle(page, [
+      `[data-timeline-part-id="${shellID}"]`,
+      `[data-timeline-part-id="${followingID}"]`,
+    ])
+    const regions = defineVisualRegions({
+      shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
+    await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
+    await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "narrow-shell",
+      trace,
+      visualPlan(
+        regions,
+        [
+          { type: "required", regions: ["shell", "following"] },
+          { type: "unique", regions: ["shell", "following"] },
+          { type: "stable", regions: ["shell", "following"] },
+          { type: "opacity", regions: "all" },
+          { type: "continuity", regions: "all" },
+          { type: "motion", regions: "all", maxPositionReversals: 0 },
+          { type: "label-stability", regions: "all" },
+          { type: "preserve-bottom-anchor" },
+          { type: "flow", regions: ["shell", "following"] },
+        ],
+        { perMarker: true },
+      ),
+    )
+  })
+
+  test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
+    const shellID = "prt_resize_01_shell"
+    const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
+    const followingID = "prt_resize_04_following"
+    await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage([
+          shell(shellID, "completed", wideLines(15)),
+          toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
+          toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
+          textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
+        ]),
+      ],
+      settings: { shellToolPartsExpanded: true },
+      cpuRate: 4,
+      seedHistory: true,
+    })
+    const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
+    const regions = defineVisualRegions({
+      shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+      context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    await page.setViewportSize({ width: 430, height: 800 })
+    await page.waitForTimeout(500)
+    await page.setViewportSize({ width: 900, height: 800 })
+    await page.waitForTimeout(500)
+    await page.setViewportSize({ width: 1400, height: 900 })
+    await page.waitForTimeout(500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "responsive-resize",
+      trace,
+      visualPlan(regions, [
+        { type: "required", regions: ["shell", "context", "following"] },
+        { type: "unique", regions: ["shell", "context", "following"] },
+        { type: "stable", regions: ["shell", "context", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
+        { type: "label-stability", regions: "all" },
+        { type: "flow", regions: ["shell", "context", "following"] },
+      ]),
+    )
+  })
+})
+
+function history(count: number, offset = 0): TimelineMessage[] {
+  return Array.from({ length: count }, (_, index) => {
+    const value = index + offset
+    const prefix = `msg_0${String(value).padStart(3, "0")}_history`
+    const userID = `${prefix}_a_user`
+    return [
+      userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
+      assistantMessage(
+        [
+          textPart(
+            `prt_history_${String(value).padStart(3, "0")}`,
+            `Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
+          ),
+        ],
+        {
+          id: `${prefix}_b_assistant`,
+          parentID: userID,
+          created: 1699990001000 + value * 10_000,
+        },
+      ),
+    ]
+  }).flat()
+}
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}
+
+function wideLines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
+}

+ 192 - 0
packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts

@@ -0,0 +1,192 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantID,
+  assistantMessage,
+  event,
+  partUpdated,
+  setupTimeline,
+  textPart,
+  toolPart,
+  userMessage,
+  waitForVisualSettle,
+} from "./fixture"
+
+const inputs = {
+  read: { filePath: "src/a.ts", offset: 0, limit: 120 },
+  glob: { path: ".", pattern: "**/*.ts" },
+  grep: { path: ".", pattern: "stable", include: "*.ts" },
+  list: { path: "src" },
+}
+
+test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
+  const firstID = "prt_append_01_read"
+  const followingID = "prt_append_99_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
+        completed: false,
+      }),
+    ],
+    cpuRate: 4,
+  })
+  const initialGroup = `[data-timeline-part-ids="${firstID}"]`
+  await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
+  await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
+  const regions = defineVisualRegions({
+    context: {
+      selector: '[data-timeline-part-ids^="prt_append_01_read"]',
+      closest: '[data-timeline-row="AssistantPart"]',
+    },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
+  await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
+  await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "context-append",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["context", "following"] },
+        { type: "unique", regions: ["context", "following"] },
+        { type: "stable", regions: ["context", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: ["following"], maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "preserve-bottom-anchor" },
+        { type: "flow", regions: ["context", "following"] },
+      ],
+      { perMarker: true },
+    ),
+  )
+  await expect(
+    page.locator(
+      '[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
+    ),
+  ).toBeVisible()
+  await expect(
+    page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
+  ).toHaveAttribute("aria-expanded", "true")
+})
+
+test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
+  const textID = "prt_split_02_text"
+  const followingID = "prt_split_99_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart("prt_split_01_read", "read", "completed", inputs.read),
+        textPart(textID, "Boundary"),
+        toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
+        textPart(followingID, "Following split groups"),
+      ]),
+    ],
+    cpuRate: 4,
+  })
+  const regions = defineVisualRegions({
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
+    500,
+  )
+  await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
+  await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
+  await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "context-split-merge",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["following"] },
+        { type: "unique", regions: ["following"] },
+        { type: "stable", regions: ["following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 1 },
+        { type: "label-stability", regions: "all" },
+      ],
+      { perMarker: true },
+    ),
+  )
+})
+
+test("removing the first context member replaces the group once without overlapping following content", async ({
+  page,
+}, testInfo) => {
+  const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
+  const followingID = "prt_key_99_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(ids[0]!, "read", "completed", inputs.read),
+        toolPart(ids[1]!, "glob", "completed", inputs.glob),
+        toolPart(ids[2]!, "grep", "completed", inputs.grep),
+        textPart(followingID, "Following replaced group"),
+      ]),
+    ],
+    cpuRate: 4,
+  })
+  const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
+  const originalRowKey = await original.evaluate((element) =>
+    element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
+  )
+  await original.locator('[data-slot="collapsible-trigger"]').click()
+  await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
+  const regions = defineVisualRegions({
+    context: {
+      selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
+      closest: '[data-timeline-row="AssistantPart"]',
+    },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
+    500,
+  )
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "context-first-remove",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["context", "following"] },
+      { type: "unique", regions: ["context", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 0 },
+      { type: "label-stability", regions: "all" },
+      { type: "flow", regions: ["context", "following"] },
+    ]),
+  )
+  await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
+  expect(
+    await page
+      .locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
+      .evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
+  ).toBe(originalRowKey)
+  await expect(
+    page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
+  ).toHaveAttribute("aria-expanded", "true")
+})

+ 113 - 0
packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts

@@ -0,0 +1,113 @@
+import { test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  shell,
+  textPart,
+  userMessage,
+  waitForVisualSettle,
+} from "./fixture"
+
+// Fractional scaling exercises different browser rounding than the baseline.
+for (const deviceScaleFactor of [1, 1.25]) {
+  test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
+    const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
+    const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
+    const timeline = await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
+          completed: false,
+        }),
+      ],
+      settings: { shellToolPartsExpanded: true },
+      cpuRate: 4,
+      deviceScaleFactor,
+      seedHistory: true,
+    })
+    await waitForVisualSettle(page, [
+      `[data-timeline-part-id="${shellID}"]`,
+      `[data-timeline-part-id="${followingID}"]`,
+    ])
+    const regions = defineVisualRegions({
+      shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
+    await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
+  })
+}
+
+for (const reducedMotion of [true]) {
+  test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
+    page,
+  }, testInfo) => {
+    const shellID = `prt_motion_${reducedMotion}_01_shell`
+    const followingID = `prt_motion_${reducedMotion}_02_following`
+    const timeline = await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
+          completed: false,
+        }),
+      ],
+      settings: { shellToolPartsExpanded: true },
+      reducedMotion,
+      cpuRate: 4,
+      seedHistory: true,
+    })
+    await waitForVisualSettle(page, [
+      `[data-timeline-part-id="${shellID}"]`,
+      `[data-timeline-part-id="${followingID}"]`,
+    ])
+    const regions = defineVisualRegions({
+      shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
+  })
+}
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}
+
+function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
+  regions: Regions & Record<"shell" | "following", { selector: string }>,
+) {
+  return visualPlan(
+    regions,
+    [
+      { type: "required", regions: ["shell", "following"] },
+      { type: "unique", regions: ["shell", "following"] },
+      { type: "stable", regions: ["shell", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 0 },
+      { type: "label-stability", regions: "all" },
+      { type: "preserve-bottom-anchor" },
+      { type: "flow", regions: ["shell", "following"] },
+    ],
+    { perMarker: true },
+  )
+}

+ 121 - 0
packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts

@@ -0,0 +1,121 @@
+import { test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  textPart,
+  toolPart,
+  userMessage,
+  waitForVisualSettle,
+} from "./fixture"
+
+const profiles = [
+  { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
+  {
+    name: "multi patch",
+    tool: "apply_patch",
+    input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
+  },
+] as const
+
+for (const profile of profiles) {
+  test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
+    const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
+    const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
+    const timeline = await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage(
+          [
+            toolPart(partID, profile.tool, "pending", profile.input),
+            textPart(followingID, `Following ${profile.name}`),
+          ],
+          { completed: false },
+        ),
+      ],
+      settings: { editToolPartsExpanded: true },
+      cpuRate: 4,
+    })
+    await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
+    const regions = defineVisualRegions({
+      tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
+    await timeline.send(partUpdated(completedPart(partID, profile)), 900)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      `file-${profile.name}`,
+      trace,
+      visualPlan(
+        regions,
+        [
+          { type: "required", regions: ["tool", "following"] },
+          { type: "unique", regions: ["tool", "following"] },
+          { type: "stable", regions: ["tool", "following"] },
+          { type: "opacity", regions: "all" },
+          { type: "continuity", regions: "all" },
+          { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
+          { type: "label-stability", regions: "all" },
+          { type: "preserve-bottom-anchor" },
+          { type: "flow", regions: ["tool", "following"] },
+        ],
+        { perMarker: true },
+      ),
+    )
+  })
+}
+
+function completedPart(partID: string, profile: (typeof profiles)[number]) {
+  if (profile.tool === "edit") {
+    return toolPart(partID, profile.tool, "completed", profile.input, {
+      metadata: {
+        filediff: {
+          file: "src/edit.ts",
+          additions: 50,
+          deletions: 50,
+          before: source(50, false),
+          after: source(50, true),
+        },
+      },
+    })
+  }
+  const files = [
+    patchFile("src/a.ts", "update"),
+    patchFile("src/b.ts", "add"),
+    patchFile("src/old.ts", "delete"),
+    { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
+  ]
+  return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
+}
+
+function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
+  return {
+    filePath,
+    relativePath: filePath,
+    type,
+    additions: type === "delete" ? 0 : 20,
+    deletions: type === "add" ? 0 : 20,
+    before: type === "add" ? undefined : source(20, false),
+    after: type === "delete" ? undefined : source(20, true),
+  }
+}
+
+function source(count: number, changed: boolean) {
+  return Array.from(
+    { length: count },
+    (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
+  ).join("")
+}

+ 113 - 0
packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts

@@ -0,0 +1,113 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  textPart,
+  toolPart,
+  userMessage,
+  waitForVisualSettle,
+} from "./fixture"
+
+test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
+  const patchID = "prt_incremental_01_patch"
+  const followingID = "prt_incremental_02_following"
+  const first = patchFile("src/a.ts", "update")
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage(
+        [
+          toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
+          textPart(followingID, "Following incremental patch"),
+        ],
+        { completed: false },
+      ),
+    ],
+    settings: { editToolPartsExpanded: true },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
+  const regions = defineVisualRegions({
+    patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  const second = patchFile("src/b.ts", "add")
+  const third = patchFile("src/old.ts", "delete")
+  await timeline.send(
+    partUpdated(
+      toolPart(
+        patchID,
+        "apply_patch",
+        "running",
+        { files: [first.filePath, second.filePath] },
+        { metadata: { files: [first, second] } },
+      ),
+    ),
+    240,
+  )
+  await timeline.send(
+    partUpdated(
+      toolPart(
+        patchID,
+        "apply_patch",
+        "completed",
+        { files: [first.filePath, second.filePath, third.filePath] },
+        { metadata: { files: [first, second, third] } },
+      ),
+    ),
+    800,
+  )
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "incremental-patch",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["patch", "following"] },
+        { type: "unique", regions: ["patch", "following"] },
+        { type: "stable", regions: ["patch", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: ["following"], maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "preserve-bottom-anchor" },
+        { type: "flow", regions: ["patch", "following"] },
+      ],
+      { perMarker: true },
+    ),
+  )
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
+})
+
+function patchFile(filePath: string, type: "add" | "update" | "delete") {
+  return {
+    filePath,
+    relativePath: filePath,
+    type,
+    additions: type === "delete" ? 0 : 4,
+    deletions: type === "add" ? 0 : 3,
+    before: type === "add" ? undefined : source(false),
+    after: type === "delete" ? undefined : source(true),
+  }
+}
+
+function source(changed: boolean) {
+  return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
+    "",
+  )
+}

+ 68 - 0
packages/app/e2e/performance/timeline-stability/fixture.test.ts

@@ -0,0 +1,68 @@
+import { describe, expect, test } from "bun:test"
+import {
+  assistantMessage,
+  event,
+  toolPart,
+  userMessage,
+  validateTimelineEvent,
+  validateTimelineMessages,
+  type PartSeed,
+} from "./fixture"
+
+describe("timeline fixture validation", () => {
+  test("accepts a valid timeline", () => {
+    expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
+  })
+
+  test("rejects malformed SDK values at runtime", () => {
+    expect(() =>
+      assistantMessage([], {
+        error: { name: "APIError", data: { message: "failed" } } as never,
+      }),
+    ).toThrow()
+    expect(() =>
+      validateTimelineEvent({
+        directory: "C:/OpenCode/TimelineStability",
+        payload: {
+          id: "evt_invalid_status",
+          type: "session.status",
+          properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
+        },
+      }),
+    ).toThrow()
+  })
+
+  test("rejects duplicate IDs and orphan assistants", () => {
+    expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
+    expect(() =>
+      validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
+    ).toThrow(/parent user/)
+  })
+
+  test("assigns deterministic event IDs", () => {
+    const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
+    const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
+    expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
+    expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
+  })
+})
+
+if (false) {
+  const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
+  userMessage([userSeed])
+
+  // @ts-expect-error Tool completion fields are not valid while pending.
+  toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
+  // @ts-expect-error Tool completion fields are not valid while running.
+  toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
+  // @ts-expect-error Tool error fields are not valid after completion.
+  toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
+
+  assistantMessage([
+    // @ts-expect-error Agent references belong to user messages, not assistant messages.
+    { id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
+  ])
+
+  // @ts-expect-error Retry status events require message and next.
+  event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
+}

+ 561 - 0
packages/app/e2e/performance/timeline-stability/fixture.ts

@@ -0,0 +1,561 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { Event } from "@opencode-ai/schema/event"
+import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
+import { SessionV1 } from "@opencode-ai/schema/session-v1"
+import type {
+  AssistantMessage,
+  GlobalEvent,
+  Message,
+  Part,
+  Session,
+  SessionStatus,
+  ToolPart,
+  ToolState,
+  UserMessage,
+} from "@opencode-ai/sdk/v2/client"
+import { expect, type Page } from "@playwright/test"
+import { Schema } from "effect"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+import { installSseTransport } from "../../utils/sse-transport"
+import { expectSessionTitle } from "../../utils/waits"
+
+export const directory = "C:/OpenCode/TimelineStability"
+export const projectID = "proj_timeline_stability"
+export const sessionID = "ses_timeline_stability"
+export const userID = "msg_1000_timeline_user"
+export const assistantID = "msg_1001_timeline_assistant"
+export const title = "Timeline visual stability"
+export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
+
+type TimelinePayload = Extract<
+  GlobalEvent["payload"],
+  {
+    type:
+      | "message.updated"
+      | "message.removed"
+      | "message.part.updated"
+      | "message.part.removed"
+      | "message.part.delta"
+      | "session.status"
+  }
+>
+
+type DeepReadonly<Value> = Value extends readonly unknown[]
+  ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
+  : Value extends object
+    ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
+    : Value
+
+export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
+export type EventPayload = TimelineEvent
+export type ToolStatus = ToolState["status"]
+export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
+
+type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
+type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
+type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
+export type PartSeed<Owner extends Message["role"]> =
+  OwnedPart<Owner> extends infer Candidate
+    ? Candidate extends Part
+      ? Omit<Candidate, "sessionID" | "messageID">
+      : never
+    : never
+
+type ToolOptions<State extends ToolStatus> = State extends "pending"
+  ? { output?: never; title?: never; metadata?: never; error?: never }
+  : State extends "running"
+    ? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
+    : State extends "error"
+      ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
+      : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
+
+const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
+const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
+const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
+const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
+const timelineEventSchema = Schema.Union([
+  eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
+  eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
+  eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
+  eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
+  eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
+  eventSchema("session.status", SessionStatusEvent.Status.data),
+])
+const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
+let eventSequence = 0
+
+export async function setupTimeline(
+  page: Page,
+  input: {
+    messages?: TimelineMessage[]
+    settings?: Record<string, boolean>
+    sessions?: Session[]
+    cpuRate?: number
+    viewport?: { width: number; height: number }
+    eventRetry?: number
+    reducedMotion?: boolean
+    locale?: string
+    deviceScaleFactor?: number
+    seedHistory?: boolean
+  } = {},
+) {
+  const sessions = input.sessions ?? [session()]
+  const messages = validateTimelineMessages([
+    ...(input.seedHistory ? historyMessages(18) : []),
+    ...(input.messages ?? [userMessage(), assistantMessage()]),
+  ])
+  const active = messages.findLast((message) => message.info.role === "assistant")
+  const initialStatus = decodeStatus(
+    active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
+    decodeOptions,
+  )
+  const transport = await installSseTransport<EventPayload>(page, {
+    server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
+    retry: input.eventRetry ?? 20,
+  })
+  await mockOpenCodeServer(page, {
+    directory,
+    project: project(),
+    provider: provider(),
+    sessions,
+    sessionStatus: { [sessionID]: initialStatus },
+    pageMessages: () => ({
+      items: messages,
+    }),
+  })
+  await page.addInitScript((settings) => {
+    localStorage.setItem(
+      "settings.v3",
+      JSON.stringify({
+        general: {
+          editToolPartsExpanded: false,
+          shellToolPartsExpanded: false,
+          showReasoningSummaries: false,
+          showSessionProgressBar: true,
+          ...settings,
+        },
+      }),
+    )
+  }, input.settings ?? {})
+  if (input.locale) {
+    await page.addInitScript((locale) => {
+      localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
+    }, input.locale)
+  }
+  if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
+  await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
+  if (input.deviceScaleFactor) {
+    const devtools = await page.context().newCDPSession(page)
+    const viewport = input.viewport ?? { width: 1400, height: 900 }
+    await devtools.send("Emulation.setDeviceMetricsOverride", {
+      width: viewport.width,
+      height: viewport.height,
+      deviceScaleFactor: input.deviceScaleFactor,
+      mobile: false,
+    })
+  }
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await transport.waitForConnection()
+  await expectSessionTitle(page, title)
+  if (input.cpuRate && input.cpuRate > 1) {
+    const devtools = await page.context().newCDPSession(page)
+    await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
+  }
+
+  return {
+    transport,
+    async send(event: TimelineEvent, delay = 0) {
+      const valid = validateTimelineEvent(event)
+      await transport.send(valid, { marker: describeEvent(valid) })
+      if (delay) await page.waitForTimeout(delay)
+    },
+    async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
+      for (const item of sequence) {
+        const valid = validateTimelineEvent(item.event)
+        await transport.send(valid, { marker: describeEvent(valid) })
+        await page.waitForTimeout(item.delay)
+      }
+    },
+    async settle(frames = 3) {
+      await page.evaluate(
+        (frames) =>
+          new Promise<void>((resolve) => {
+            let remaining = frames
+            const tick = () => {
+              remaining--
+              if (remaining <= 0) return resolve()
+              requestAnimationFrame(tick)
+            }
+            requestAnimationFrame(tick)
+          }),
+        frames,
+      )
+    },
+    async waitForPart(partID: string) {
+      await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
+    },
+  }
+}
+
+function describeEvent(event: EventPayload) {
+  if (event.payload.type === "message.part.updated") {
+    const part = event.payload.properties.part
+    return [
+      event.payload.type,
+      part.id,
+      part.type === "tool" ? part.tool : part.type,
+      part.type === "tool" ? part.state.status : undefined,
+    ]
+      .filter(Boolean)
+      .join(":")
+  }
+  if (event.payload.type === "session.status") {
+    const status = event.payload.properties.status
+    return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
+      .filter((value) => value !== undefined)
+      .join(":")
+  }
+  return event.payload.type
+}
+
+export function event<const Type extends TimelinePayload["type"]>(
+  type: Type,
+  properties: Extract<TimelinePayload, { type: Type }>["properties"],
+): TimelineEvent
+export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
+  return validateTimelineEvent({
+    directory,
+    payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
+  })
+}
+
+export function validateTimelineEvent(input: unknown): TimelineEvent {
+  return decodeEvent(input, decodeOptions)
+}
+
+export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
+  input.forEach((message) => decodeMessage(message, decodeOptions))
+  const messages = [...input]
+  const messageIDs = new Set<string>()
+  const partIDs = new Set<string>()
+  const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
+
+  messages.forEach((message) => {
+    if (messageIDs.has(message.info.id))
+      throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
+    messageIDs.add(message.info.id)
+    if (message.info.role === "assistant" && !users.has(message.info.parentID))
+      throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
+    message.parts.forEach((part) => {
+      if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
+        throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
+      if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
+        throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
+      if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
+        throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
+      if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
+      partIDs.add(part.id)
+    })
+  })
+  return messages
+}
+
+export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
+  await page.waitForFunction(
+    ({ selectors, stableFrames }) => {
+      const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
+      if (elements.some((element) => !element)) return false
+      return new Promise<boolean>((resolve) => {
+        let stable = 0
+        let previous = ""
+        const sample = () => {
+          const signature = JSON.stringify(
+            elements.map((element) => {
+              const rect = element!.getBoundingClientRect()
+              return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
+            }),
+          )
+          stable = signature === previous ? stable + 1 : 0
+          previous = signature
+          const ordered = elements
+            .slice(1)
+            .every(
+              (element, index) =>
+                elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
+            )
+          if (stable >= stableFrames && ordered) return resolve(true)
+          requestAnimationFrame(sample)
+        }
+        requestAnimationFrame(sample)
+      })
+    },
+    { selectors, stableFrames },
+  )
+}
+
+export function historyMessages(count: number): TimelineMessage[] {
+  return Array.from({ length: count }, (_, index) => {
+    const value = String(index).padStart(4, "0")
+    const historyUserID = `msg_0${value}_history_a_user`
+    return [
+      userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
+      assistantMessage(
+        [
+          {
+            id: `prt_0${value}_history_text`,
+            type: "text",
+            text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
+          },
+        ],
+        {
+          id: `msg_0${value}_history_b_assistant`,
+          parentID: historyUserID,
+          created: 1690000001000 + index * 10_000,
+        },
+      ),
+    ]
+  }).flat()
+}
+
+export function partUpdated(part: Part | PartSeed<"assistant">) {
+  const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
+  decodePart(owned, decodeOptions)
+  return event("message.part.updated", {
+    sessionID,
+    part: owned,
+    time: 1700000002000,
+  })
+}
+
+export function partDelta(partID: string, delta: string, messageID = assistantID) {
+  return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
+}
+
+export function messageUpdated(info: Message) {
+  return event("message.updated", { sessionID, info })
+}
+
+export function status(type: SessionStatus["type"], attempt = 1) {
+  return event("session.status", {
+    sessionID,
+    status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
+  })
+}
+
+export function userMessage(
+  parts?: PartSeed<"user">[],
+  input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
+): Extract<TimelineMessage, { info: { role: "user" } }> {
+  const id = input.id ?? userID
+  const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
+  const message = {
+    info: {
+      id,
+      sessionID,
+      role: "user",
+      time: { created: input.created ?? 1700000000000 },
+      summary: input.summary ?? { diffs: [] },
+      agent: "build",
+      model,
+    },
+    parts: seeds.map((part) => ({
+      ...part,
+      sessionID,
+      messageID: id,
+    })),
+  } satisfies Extract<TimelineMessage, { info: { role: "user" } }>
+  decodeMessage(message, decodeOptions)
+  return message
+}
+
+export function assistantMessage(
+  parts: PartSeed<"assistant">[] = [],
+  input: {
+    id?: string
+    parentID?: string
+    completed?: boolean
+    error?: AssistantMessage["error"]
+    created?: number
+  } = {},
+): Extract<TimelineMessage, { info: { role: "assistant" } }> {
+  const id = input.id ?? assistantID
+  const message = {
+    info: {
+      id,
+      sessionID,
+      role: "assistant",
+      time: {
+        created: input.created ?? 1700000001000,
+        ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
+      },
+      parentID: input.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",
+      ...(input.error ? { error: input.error } : {}),
+    },
+    parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
+  } satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
+  decodeMessage(message, decodeOptions)
+  return message
+}
+
+export function userText(
+  text: string,
+  input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
+): Extract<PartSeed<"user">, { type: "text" }> {
+  return { id: "prt_user_text", type: "text", text, ...input }
+}
+
+export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
+  return { id, type: "text", text }
+}
+
+export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
+  return { id, type: "reasoning", text, time: { start: 1700000001000 } }
+}
+
+export function toolPart(
+  id: string,
+  tool: string,
+  state: "pending",
+  input: Record<string, unknown>,
+  options?: ToolOptions<"pending">,
+): Omit<ToolPart, "sessionID" | "messageID">
+export function toolPart(
+  id: string,
+  tool: string,
+  state: "running",
+  input: Record<string, unknown>,
+  options?: ToolOptions<"running">,
+): Omit<ToolPart, "sessionID" | "messageID">
+export function toolPart(
+  id: string,
+  tool: string,
+  state: "completed",
+  input: Record<string, unknown>,
+  options?: ToolOptions<"completed">,
+): Omit<ToolPart, "sessionID" | "messageID">
+export function toolPart(
+  id: string,
+  tool: string,
+  state: "error",
+  input: Record<string, unknown>,
+  options?: ToolOptions<"error">,
+): Omit<ToolPart, "sessionID" | "messageID">
+export function toolPart(
+  id: string,
+  tool: string,
+  state: ToolStatus,
+  input: Record<string, unknown>,
+  options: ToolOptions<ToolStatus> = {},
+): Omit<ToolPart, "sessionID" | "messageID"> {
+  const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
+  if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
+  if (state === "running")
+    return {
+      ...base,
+      state: {
+        status: state,
+        input,
+        title: options.title,
+        metadata: options.metadata ?? {},
+        time: { start: 1700000001000 },
+      },
+    }
+  if (state === "error")
+    return {
+      ...base,
+      state: {
+        status: state,
+        input,
+        error: options.error ?? "Tool failed",
+        metadata: options.metadata ?? {},
+        time: { start: 1700000001000, end: 1700000002000 },
+      },
+    }
+  return {
+    ...base,
+    state: {
+      status: state,
+      input,
+      output: options.output ?? "Completed",
+      title: options.title ?? tool,
+      metadata: options.metadata ?? {},
+      time: { start: 1700000001000, end: 1700000002000 },
+    },
+  }
+}
+
+export function shell(
+  id: string,
+  state: ToolStatus,
+  output = "",
+  command = `echo ${id}`,
+): Omit<ToolPart, "sessionID" | "messageID"> {
+  if (state === "pending") return toolPart(id, "bash", state, { command })
+  if (state === "running")
+    return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
+  if (state === "error")
+    return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
+  return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
+}
+
+export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
+  return { ...info, time: { ...info.time, completed: 1700000003000 } }
+}
+
+export function project() {
+  return {
+    id: projectID,
+    worktree: directory,
+    vcs: "git",
+    name: "timeline-stability",
+    time: { created: 1700000000000, updated: 1700000000000 },
+    sandboxes: [],
+  }
+}
+
+export function session(input: Partial<Session> = {}): Session {
+  return {
+    id: sessionID,
+    slug: "timeline-stability",
+    projectID,
+    directory,
+    title,
+    version: "dev",
+    time: { created: 1700000000000, updated: 1700000000000 },
+    ...input,
+  }
+}
+
+function eventSchema<
+  const Type extends TimelinePayload["type"],
+  const Properties extends Schema.Codec<unknown, unknown>,
+>(type: Type, properties: Properties) {
+  return Schema.Struct({
+    directory: Schema.String,
+    project: Schema.optional(Schema.String),
+    workspace: Schema.optional(Schema.String),
+    payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
+  })
+}
+
+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" },
+  }
+}

+ 242 - 0
packages/app/e2e/performance/timeline-stability/interaction.spec.ts

@@ -0,0 +1,242 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
+
+test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
+  const shellID = "prt_interaction_01_shell"
+  const followingID = "prt_interaction_02_following"
+  await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
+    ],
+    settings: { shellToolPartsExpanded: false },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
+  await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  const plan = visualPlan(regions, [
+    { type: "required", regions: ["shell", "following"] },
+    { type: "unique", regions: ["shell", "following"] },
+    { type: "stable", regions: ["shell", "following"] },
+    { type: "opacity", regions: "all" },
+    { type: "continuity", regions: "all" },
+    { type: "motion", regions: "all", maxPositionReversals: 0 },
+    { type: "label-stability", regions: "all" },
+    { type: "preserve-bottom-anchor" },
+    { type: "flow", regions: ["shell", "following"] },
+  ])
+  await startVisualProbe(page, regions)
+  await trigger.click()
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await page.waitForTimeout(500)
+  const expanded = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "shell-expand", expanded, plan)
+
+  await startVisualProbe(page, regions)
+  await trigger.click()
+  await expect(trigger).toHaveAttribute("aria-expanded", "false")
+  await page.waitForTimeout(500)
+  const collapsed = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
+})
+
+test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
+  const ids = [
+    "prt_interaction_01_read",
+    "prt_interaction_02_glob",
+    "prt_interaction_03_grep",
+    "prt_interaction_04_list",
+  ]
+  const group = `[data-timeline-part-ids="${ids.join(",")}"]`
+  const followingID = "prt_interaction_context_following"
+  await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
+        toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
+        toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
+        toolPart(ids[3]!, "list", "completed", { path: "src" }),
+        textPart(followingID, "Following context expansion"),
+      ]),
+    ],
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
+  await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
+  for (const [name, expanded] of [
+    ["context-expand", true],
+    ["context-collapse", false],
+    ["context-reexpand", true],
+  ] as const) {
+    const regions = defineVisualRegions({
+      context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    await trigger.click()
+    await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
+    await page.waitForTimeout(500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      name,
+      trace,
+      visualPlan(regions, [
+        { type: "required", regions: ["context", "following"] },
+        { type: "unique", regions: ["context", "following"] },
+        { type: "stable", regions: ["context", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "preserve-bottom-anchor" },
+        { type: "flow", regions: ["context", "following"] },
+      ]),
+    )
+  }
+})
+
+test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
+  const editID = "prt_interaction_edit"
+  const followingID = "prt_interaction_edit_following"
+  await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(
+          editID,
+          "edit",
+          "completed",
+          { filePath: "src/edit.ts" },
+          {
+            metadata: {
+              filediff: {
+                file: "src/edit.ts",
+                additions: 40,
+                deletions: 40,
+                before: source(40, false),
+                after: source(40, true),
+              },
+            },
+          },
+        ),
+        textPart(followingID, "Following edit expansion"),
+      ]),
+    ],
+    settings: { editToolPartsExpanded: false },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
+  await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
+  const regions = defineVisualRegions({
+    edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await trigger.click()
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await page.waitForTimeout(900)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "edit-expand",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["edit", "following"] },
+      { type: "unique", regions: ["edit", "following"] },
+      { type: "stable", regions: ["edit", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
+      { type: "label-stability", regions: "all" },
+      { type: "preserve-bottom-anchor" },
+      { type: "flow", regions: ["edit", "following"] },
+    ]),
+  )
+})
+
+test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
+  const firstUser = userMessage(undefined, {
+    summary: {
+      diffs: Array.from({ length: 12 }, (_, index) => ({
+        file: `src/diff-${index}.ts`,
+        additions: 1,
+        deletions: 1,
+        patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
+      })),
+    },
+  })
+  const nextUserID = "msg_2000_diff_interaction_user"
+  await setupTimeline(page, {
+    messages: [
+      firstUser,
+      assistantMessage(),
+      userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
+      assistantMessage([], {
+        id: "msg_2001_diff_interaction_assistant",
+        parentID: nextUserID,
+        created: 1700000011000,
+      }),
+    ],
+    cpuRate: 4,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  await scroller.evaluate((element) => (element.scrollTop = 0))
+  const diff = page.locator('[data-timeline-row="DiffSummary"]')
+  const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
+  await expect(diff).toBeVisible()
+  const regions = defineVisualRegions({
+    diff: { selector: '[data-timeline-row="DiffSummary"]' },
+    following: { selector: `[data-message-id="${nextUserID}"]` },
+  })
+  await startVisualProbe(page, regions)
+  await page.getByText(/show all/i).click()
+  await page.waitForTimeout(500)
+  await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
+  await page.waitForTimeout(900)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "diff-summary-expand",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["diff", "following"] },
+      { type: "unique", regions: ["diff", "following"] },
+      { type: "stable", regions: ["diff", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
+      { type: "label-stability", regions: "all" },
+      { type: "flow", regions: ["diff", "following"] },
+    ]),
+  )
+})
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}
+
+function source(count: number, changed: boolean) {
+  return Array.from(
+    { length: count },
+    (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
+  ).join("")
+}

+ 157 - 0
packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts

@@ -0,0 +1,157 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  mapVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  completedAssistantInfo,
+  messageUpdated,
+  partDelta,
+  partUpdated,
+  reasoningPart,
+  setupTimeline,
+  shell,
+  status,
+  textPart,
+  userMessage,
+  waitForVisualSettle,
+} from "./fixture"
+
+test.describe("timeline visual lifecycle stability", () => {
+  test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
+    test.setTimeout(180_000)
+    const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
+    const initial = ids.map((id) => shell(id, "running"))
+    const followingID = "prt_parallel_04_following"
+    const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
+      completed: false,
+    })
+    const timeline = await setupTimeline(page, {
+      messages: [userMessage(), assistant],
+      settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
+      cpuRate: 4,
+      eventRetry: 24,
+      seedHistory: true,
+    })
+    await timeline.send(status("busy"), 150)
+    for (const id of ids) await timeline.waitForPart(id)
+    const scroller = page.locator(".scroll-view__viewport", {
+      has: page.locator('[data-timeline-row="AssistantPart"]'),
+    })
+    await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
+    const regions = defineVisualRegions({
+      prt_shell_empty: shellRegion(ids[0]),
+      prt_shell_short: shellRegion(ids[1]),
+      prt_shell_long: shellRegion(ids[2]),
+      following: shellRegion(followingID),
+    })
+    await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
+    await startVisualProbe(page, regions)
+    await timeline.sendAll([
+      { event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
+      { event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
+      { event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
+      { event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
+      { event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
+      { event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
+      { event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
+      { event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
+      { event: status("idle"), delay: 700 },
+    ])
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "parallel-shells",
+      trace,
+      visualPlan(
+        regions,
+        [
+          { type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
+          { type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
+          { type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
+          { type: "opacity", regions: "all" },
+          { type: "continuity", regions: "all" },
+          { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
+          { type: "label-stability", regions: "all" },
+          { type: "preserve-bottom-anchor" },
+          { type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
+        ],
+        { perMarker: true },
+      ),
+    )
+    await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
+
+    const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
+    await short.locator('[data-slot="collapsible-trigger"]').click()
+    await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
+    await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
+    await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
+  })
+
+  test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
+    page,
+  }, testInfo) => {
+    const reasoningID = "prt_reasoning_visible"
+    const textID = "prt_streamed_text"
+    const assistant = assistantMessage([], { completed: false })
+    const timeline = await setupTimeline(page, {
+      messages: [userMessage(), assistant],
+      settings: { showReasoningSummaries: true },
+      cpuRate: 4,
+    })
+    await timeline.send(status("busy"), 120)
+    await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
+
+    const regions = defineVisualRegions({
+      thinking: { selector: '[data-timeline-row="Thinking"]' },
+      reasoning: {
+        selector: `[data-timeline-part-id="${reasoningID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+      text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    })
+    await startVisualProbe(page, regions)
+    await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
+    await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
+    await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
+    await timeline.waitForPart(reasoningID)
+    await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+    await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
+    await timeline.send(partDelta(textID, " **stable"), 90)
+    await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
+    await timeline.send(partDelta(textID, "](https://example.com)."), 220)
+    await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
+    await timeline.send(status("idle"), 500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "reasoning-text-handoff",
+      trace,
+      visualPlan(regions, [
+        { type: "required", regions: ["reasoning", "text"] },
+        { type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
+        { type: "unique", regions: ["reasoning", "text"] },
+        { type: "stable", regions: ["reasoning", "text"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxReversals: 4 },
+        { type: "label-stability", regions: "all" },
+        { type: "flow", regions: ["reasoning", "text"] },
+      ]),
+    )
+    await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
+  })
+})
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}
+
+function shellRegion(id: string) {
+  return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
+}

+ 75 - 0
packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts

@@ -0,0 +1,75 @@
+import { expect, test } from "@playwright/test"
+import {
+  analyzeVisualObservations,
+  defineVisualRegions,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
+
+test("detects blanking caused by ancestor opacity", async ({ page }) => {
+  const partID = "prt_oracle_ancestor_opacity"
+  await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
+  const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
+  const regions = defineVisualRegions({
+    content: { selector: `[data-timeline-part-id="${partID}"]` },
+  })
+  await startVisualProbe(page, regions)
+  await row.evaluate((element) => {
+    element.parentElement!.style.opacity = "0"
+  })
+  await page.waitForTimeout(50)
+  await row.evaluate((element) => {
+    element.parentElement!.style.opacity = "1"
+  })
+  await page.waitForTimeout(50)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  const issues = analyzeVisualObservations(
+    trace.samples,
+    visualPlan(regions, [
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all" },
+      { type: "label-stability", regions: "all" },
+    ]),
+  )
+
+  expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
+})
+
+test("detects root opacity when probing descendant opacity", async ({ page }) => {
+  const partID = "prt_oracle_descendant_opacity"
+  await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
+  const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
+  await row.evaluate((element) => {
+    element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
+  })
+  const regions = defineVisualRegions({
+    content: {
+      selector: `[data-timeline-part-id="${partID}"]`,
+      opacitySelectors: ['[data-probe-opacity="true"]'],
+    },
+  })
+  await startVisualProbe(page, regions)
+  await row.evaluate((element) => {
+    ;(element as HTMLElement).style.opacity = "0"
+  })
+  await page.waitForTimeout(50)
+  await row.evaluate((element) => {
+    ;(element as HTMLElement).style.opacity = "1"
+  })
+  await page.waitForTimeout(50)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  const issues = analyzeVisualObservations(
+    trace.samples,
+    visualPlan(regions, [
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all" },
+      { type: "label-stability", regions: "all" },
+    ]),
+  )
+
+  expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
+})

+ 17 - 0
packages/app/e2e/performance/timeline-stability/playwright.config.ts

@@ -0,0 +1,17 @@
+import config from "../playwright.config"
+
+export default {
+  ...config,
+  testDir: ".",
+  testMatch: "**/*.spec.ts",
+  outputDir: "../../test-results/timeline-stability",
+  reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
+  retries: 0,
+  workers: 1,
+  use: {
+    ...config.use,
+    trace: "retain-on-failure",
+    screenshot: "only-on-failure",
+    video: "retain-on-failure",
+  },
+}

+ 319 - 0
packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts

@@ -0,0 +1,319 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  shell,
+  textPart,
+  userMessage,
+  type TimelineMessage,
+} from "./fixture"
+
+test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
+  const shellID = "prt_wheel_01_shell"
+  const followingID = "prt_wheel_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      ...history(12),
+      userMessage(),
+      assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
+        completed: false,
+      }),
+    ],
+    settings: { shellToolPartsExpanded: true },
+    cpuRate: 4,
+    reducedMotion: true,
+    seedHistory: true,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
+  await scroller.evaluate((element) =>
+    element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
+  )
+  await scroller.evaluate((element) => (element.scrollTop -= 180))
+  await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
+})
+
+test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
+  const shellID = "prt_keyboard_01_shell"
+  const followingID = "prt_keyboard_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      ...history(12),
+      userMessage(),
+      assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
+        completed: false,
+      }),
+    ],
+    settings: { shellToolPartsExpanded: true },
+    cpuRate: 4,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  await scroller.focus()
+  for (let index = 0; index < 3; index++) {
+    await scroller.press("PageUp")
+    await page.waitForTimeout(250)
+  }
+  await expect
+    .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
+      timeout: 20_000,
+    })
+    .toBeGreaterThan(80)
+  await page.waitForFunction(() => {
+    const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
+      element.querySelector("[data-timeline-row]"),
+    )
+    if (!root) return false
+    return new Promise<boolean>((resolve) => {
+      const top = root.scrollTop
+      requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
+    })
+  })
+  const anchor = await scroller.evaluate((element) => {
+    const view = element.getBoundingClientRect()
+    return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
+      const rect = row.getBoundingClientRect()
+      return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
+    })?.dataset.timelineKey
+  })
+  expect(anchor).toBeTruthy()
+  const regions = defineVisualRegions({
+    anchor: { selector: `[data-timeline-key="${anchor}"]` },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
+})
+
+test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
+  const shellID = "prt_descendant_keyboard_01_shell"
+  const timeline = await setupTimeline(page, {
+    messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
+    settings: { shellToolPartsExpanded: false },
+    cpuRate: 4,
+    reducedMotion: true,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
+  const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
+  await row.evaluate((element) => element.setAttribute("tabindex", "0"))
+  await row.focus()
+  for (let index = 0; index < 3; index++) {
+    await row.press("PageUp")
+    await page.waitForTimeout(250)
+  }
+  await expect
+    .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
+    .toBeGreaterThan(5)
+  const anchor = await scroller.evaluate((element) => {
+    const view = element.getBoundingClientRect()
+    return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
+      const rect = row.getBoundingClientRect()
+      return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
+    })?.dataset.timelineKey
+  })
+  expect(anchor).toBeTruthy()
+  const regions = defineVisualRegions({
+    anchor: { selector: `[data-timeline-key="${anchor}"]` },
+  })
+  await startVisualProbe(page, regions)
+  await trigger.click()
+  await page.waitForTimeout(300)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
+})
+
+test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
+  const shellID = "prt_nested_keyboard_shell"
+  await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
+    settings: { shellToolPartsExpanded: true },
+    cpuRate: 4,
+    reducedMotion: true,
+    seedHistory: true,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
+  await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
+  await nested.focus()
+  await page.waitForFunction(() => {
+    const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
+      element.querySelector("[data-timeline-row]"),
+    )
+    if (!root) return false
+    return new Promise<boolean>((resolve) => {
+      const top = root.scrollTop
+      requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
+    })
+  })
+  const before = await scroller.evaluate((element) => element.scrollTop)
+  const nestedBefore = await nested.evaluate((element) => element.scrollTop)
+  await nested.press("PageUp")
+  await page.waitForTimeout(300)
+  expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
+  expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
+
+  await nested.evaluate((element) => (element.scrollTop = 0))
+  await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
+  const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
+  expect(boundaryBefore).toBeGreaterThan(0)
+  await nested.press("PageUp")
+  await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
+
+  const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
+  await nonOverflowing.evaluate((element) => {
+    element.setAttribute("data-scrollable", "")
+    element.setAttribute("tabindex", "0")
+  })
+  await nonOverflowing.focus()
+  const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
+  await nonOverflowing.press("PageUp")
+  await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
+})
+
+test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
+  const shellID = "prt_jump_01_shell"
+  const followingID = "prt_jump_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      ...history(20),
+      userMessage(),
+      assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
+    ],
+    settings: { shellToolPartsExpanded: true },
+    cpuRate: 4,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  await scroller.evaluate(
+    (element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
+  )
+  await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await page.getByRole("button", { name: /Jump to latest/i }).click()
+  await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
+  await page.waitForTimeout(600)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "jump-latest",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["shell", "following"] },
+      { type: "unique", regions: ["shell", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 1 },
+      { type: "label-stability", regions: "all" },
+      { type: "acquire-bottom-anchor" },
+      { type: "flow", regions: ["shell", "following"] },
+    ]),
+  )
+})
+
+test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
+  const shellID = "prt_tall_01_shell"
+  const followingID = "prt_tall_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
+    ],
+    settings: { shellToolPartsExpanded: true },
+    viewport: { width: 900, height: 360 },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "taller-than-viewport",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["shell", "following"] },
+      { type: "unique", regions: ["shell", "following"] },
+      { type: "stable", regions: ["shell", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 0 },
+      { type: "label-stability", regions: "all" },
+      { type: "preserve-bottom-anchor" },
+      { type: "flow", regions: ["shell", "following"] },
+    ]),
+  )
+})
+
+function history(count: number): TimelineMessage[] {
+  return Array.from({ length: count }, (_, index) => {
+    const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
+    const userID = `${prefix}_a_user`
+    return [
+      userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
+      assistantMessage(
+        [textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
+        {
+          id: `${prefix}_b_assistant`,
+          parentID: userID,
+          created: 1690000001000 + index * 10_000,
+        },
+      ),
+    ]
+  }).flat()
+}
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}
+
+function rowPairPlan(
+  regions: Record<"shell" | "following", { selector: string; closest?: string }>,
+  maxPositionReversals: number,
+) {
+  return visualPlan(regions, [
+    { type: "required", regions: ["shell", "following"] },
+    { type: "unique", regions: ["shell", "following"] },
+    { type: "stable", regions: ["shell", "following"] },
+    { type: "opacity", regions: "all" },
+    { type: "continuity", regions: "all" },
+    { type: "motion", regions: "all", maxPositionReversals },
+    { type: "label-stability", regions: "all" },
+    { type: "flow", regions: ["shell", "following"] },
+  ])
+}
+
+function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
+  return visualPlan(regions, [
+    { type: "required", regions: ["anchor"] },
+    { type: "unique", regions: ["anchor"] },
+    { type: "stable", regions: ["anchor"] },
+    { type: "fixed", regions: ["anchor"] },
+    { type: "opacity", regions: "all" },
+    { type: "continuity", regions: "all" },
+    { type: "motion", regions: "all", maxPositionReversals: 0 },
+    { type: "label-stability", regions: "all" },
+  ])
+}

+ 255 - 0
packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts

@@ -0,0 +1,255 @@
+import { test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  shell,
+  textPart,
+  userMessage,
+  waitForVisualSettle,
+} from "./fixture"
+
+const profiles = [
+  {
+    name: "empty running to completed",
+    updates: [{ state: "completed" as const, output: "", delay: 350 }],
+  },
+  {
+    name: "50 lines arriving incrementally",
+    updates: [
+      { state: "running" as const, output: lines(1), delay: 100 },
+      { state: "running" as const, output: lines(10), delay: 160 },
+      { state: "running" as const, output: lines(25), delay: 90 },
+      { state: "running" as const, output: lines(50), delay: 220 },
+      { state: "completed" as const, output: lines(50), delay: 500 },
+    ],
+  },
+  {
+    name: "wide ANSI and CRLF output",
+    updates: [
+      {
+        state: "running" as const,
+        output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
+          "\r\n",
+        ),
+        delay: 240,
+      },
+      {
+        state: "completed" as const,
+        output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
+        delay: 500,
+      },
+    ],
+  },
+] as const
+
+for (const profile of profiles) {
+  test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
+    const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
+    const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
+    const timeline = await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
+          completed: false,
+        }),
+      ],
+      settings: { shellToolPartsExpanded: true },
+      cpuRate: 4,
+      seedHistory: true,
+    })
+    const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+    await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
+    await waitForVisualSettle(page, [
+      `[data-timeline-part-id="${shellID}"]`,
+      `[data-timeline-part-id="${followingID}"]`,
+    ])
+    const regions = defineVisualRegions({
+      shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    for (const update of profile.updates) {
+      await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
+    }
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      `shell-${profiles.indexOf(profile)}`,
+      trace,
+      visualPlan(
+        regions,
+        [
+          { type: "required", regions: ["shell", "following"] },
+          { type: "unique", regions: ["shell", "following"] },
+          { type: "stable", regions: ["shell", "following"] },
+          { type: "opacity", regions: "all" },
+          { type: "continuity", regions: "all" },
+          { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
+          { type: "label-stability", regions: "all" },
+          { type: "preserve-bottom-anchor" },
+          { type: "flow", regions: ["shell", "following"] },
+        ],
+        { perMarker: true },
+      ),
+    )
+  })
+}
+
+test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
+  const shellID = "prt_matrix_collapsed_01_shell"
+  const followingID = "prt_matrix_collapsed_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
+        completed: false,
+      }),
+    ],
+    settings: { shellToolPartsExpanded: false },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
+  await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "collapsed-shell",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["shell", "following"] },
+        { type: "unique", regions: ["shell", "following"] },
+        { type: "stable", regions: ["shell", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: ["following"], maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "flow", regions: ["shell", "following"] },
+      ],
+      { perMarker: true },
+    ),
+  )
+})
+
+test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
+  const shellID = "prt_matrix_error_01_shell"
+  const followingID = "prt_matrix_error_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
+        completed: false,
+      }),
+    ],
+    settings: { shellToolPartsExpanded: true },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    partUpdated({
+      ...shell(shellID, "error"),
+      state: {
+        status: "error",
+        input: { command: `echo ${shellID}` },
+        error: "Command failed after output",
+        metadata: {},
+        time: { start: 1700000001000, end: 1700000002000 },
+      },
+    }),
+    500,
+  )
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "shell-error",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["shell", "following"] },
+        { type: "unique", regions: ["shell", "following"] },
+        { type: "stable", regions: ["shell", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: ["following"], maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "preserve-bottom-anchor" },
+        { type: "flow", regions: ["shell", "following"] },
+      ],
+      { perMarker: true },
+    ),
+  )
+})
+
+test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
+  const shellID = "prt_late_text_01_shell"
+  const followingID = "prt_late_text_02_following"
+  const timeline = await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
+    settings: { shellToolPartsExpanded: true },
+    cpuRate: 4,
+    seedHistory: true,
+  })
+  await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
+  const regions = defineVisualRegions({
+    shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: {
+      selector: `[data-timeline-part-id="${followingID}"]`,
+      closest: '[data-timeline-row="AssistantPart"]',
+    },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
+  await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
+  await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "late-text-before-shell-output",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["shell", "following"] },
+        { type: "unique", regions: ["shell", "following"] },
+        { type: "stable", regions: ["shell"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "preserve-bottom-anchor" },
+        { type: "flow", regions: ["shell", "following"] },
+      ],
+      { perMarker: true },
+    ),
+  )
+})
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}

+ 106 - 0
packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts

@@ -0,0 +1,106 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  partUpdated,
+  session,
+  sessionID,
+  setupTimeline,
+  textPart,
+  toolPart,
+  userMessage,
+} from "./fixture"
+
+test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
+  const taskID = "prt_task_link"
+  const childID = "ses_task_child"
+  const input = { description: "Inspect child", subagent_type: "explore" }
+  const timeline = await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
+    sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
+    cpuRate: 4,
+  })
+  const regions = defineVisualRegions({
+    task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
+    500,
+  )
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "task-link",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["task"] },
+      { type: "unique", regions: ["task"] },
+      { type: "stable", regions: ["task"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 0 },
+      { type: "label-stability", regions: "all" },
+    ]),
+  )
+  await expect(
+    page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
+  ).toBeVisible()
+})
+
+test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
+  const toolID = "prt_generic_mutation"
+  const followingID = "prt_generic_mutation_following"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage(
+        [
+          toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
+          textPart(followingID, "Following generic tool"),
+        ],
+        { completed: false },
+      ),
+    ],
+    cpuRate: 4,
+  })
+  const regions = defineVisualRegions({
+    tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
+    200,
+  )
+  await timeline.send(
+    partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
+    400,
+  )
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "generic-mutation",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["tool", "following"] },
+        { type: "unique", regions: ["tool", "following"] },
+        { type: "stable", regions: ["tool", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+        { type: "flow", regions: ["tool", "following"] },
+      ],
+      { perMarker: true },
+    ),
+  )
+})

+ 198 - 0
packages/app/e2e/performance/timeline-stability/tools.spec.ts

@@ -0,0 +1,198 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantMessage,
+  directory,
+  partUpdated,
+  session,
+  sessionID,
+  setupTimeline,
+  status,
+  textPart,
+  toolPart,
+  userMessage,
+} from "./fixture"
+
+test.describe("timeline tool state stability", () => {
+  test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
+    page,
+  }, testInfo) => {
+    const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
+    const inputs = {
+      webfetch: { url: "https://example.com/docs" },
+      websearch: { query: "timeline stability" },
+      task: { description: "Inspect timeline", subagent_type: "explore" },
+      skill: { name: "stability" },
+      custom: { target: "timeline", depth: 2 },
+    }
+    const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
+    const questionID = "prt_state_question"
+    const todoID = "prt_state_todo"
+    const initial = [
+      ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
+      toolPart(questionID, "question", "pending", questionInput()),
+      toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
+      textPart("prt_state_following", "Following lightweight tools"),
+    ]
+    const childID = "ses_timeline_child"
+    const timeline = await setupTimeline(page, {
+      messages: [userMessage(), assistantMessage(initial, { completed: false })],
+      sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
+      cpuRate: 4,
+    })
+    await timeline.send(status("busy"), 120)
+    for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
+    await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
+    await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
+
+    const regionIDs = [
+      "prt_state_webfetch",
+      "prt_state_websearch",
+      "prt_state_task",
+      "prt_state_skill",
+      "prt_state_custom",
+    ] as const
+    const regions = defineVisualRegions({
+      prt_state_webfetch: toolRegion(regionIDs[0]),
+      prt_state_websearch: toolRegion(regionIDs[1]),
+      prt_state_task: toolRegion(regionIDs[2]),
+      prt_state_skill: toolRegion(regionIDs[3]),
+      prt_state_custom: toolRegion(regionIDs[4]),
+    })
+    await startVisualProbe(page, regions)
+    for (const [index, id] of ids.entries()) {
+      await timeline.send(
+        partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
+        [80, 240, 100, 360, 140][index],
+      )
+    }
+    for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
+      const key = id as (typeof ids)[number]
+      const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
+      const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
+      await timeline.send(
+        partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
+        [110, 70, 280, 130, 420][index],
+      )
+    }
+    await timeline.send(
+      partUpdated(
+        toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
+      ),
+      350,
+    )
+    await timeline.waitForPart(questionID)
+    await timeline.send(status("idle"), 500)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "lightweight-tools",
+      trace,
+      visualPlan(regions, [
+        { type: "required", regions: regionIDs },
+        { type: "unique", regions: regionIDs },
+        { type: "stable", regions: regionIDs },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxReversals: 4 },
+        { type: "label-stability", regions: "all" },
+      ]),
+    )
+    await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
+    await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
+    await expect(
+      page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
+    ).toBeVisible()
+    await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
+  })
+
+  test("keeps an expanded mixed context group stable through staggered completion and error", async ({
+    page,
+  }, testInfo) => {
+    const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
+    const tools = ["read", "glob", "grep", "list"]
+    const inputs = [
+      { filePath: "src/a.ts", offset: 0, limit: 120 },
+      { path: directory, pattern: "**/*.ts" },
+      { path: directory, pattern: "stability", include: "*.ts" },
+      { path: "src" },
+    ]
+    const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
+    const timeline = await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
+      ],
+      cpuRate: 4,
+    })
+    await timeline.send(status("busy"), 100)
+    const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
+    const group = page.locator(groupSelector)
+    await expect(group).toBeVisible()
+    await group.locator('[data-slot="collapsible-trigger"]').click()
+    await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
+
+    const regions = defineVisualRegions({
+      status: {
+        selector: `${groupSelector} [data-component="tool-status-title"]`,
+        opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
+      },
+      context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: '[data-timeline-part-id="prt_ctx_following"]',
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    for (const [index, delay] of [90, 260, 70, 380].entries()) {
+      await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
+    }
+    await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
+    await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
+    await timeline.send(
+      partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
+      110,
+    )
+    await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
+    await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
+    await timeline.send(status("idle"), 700)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    await reportVisualStability(
+      testInfo,
+      "mixed-context",
+      trace,
+      visualPlan(regions, [
+        { type: "required", regions: ["context", "following"] },
+        { type: "unique", regions: ["context"] },
+        { type: "stable", regions: ["context"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxReversals: 4 },
+        { type: "label-stability", regions: "all" },
+        { type: "flow", regions: ["context", "following"] },
+      ]),
+    )
+    await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
+    await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
+    await group.locator('[data-slot="collapsible-trigger"]').click()
+    await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
+    await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
+    await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
+    await group.locator('[data-slot="collapsible-trigger"]').click()
+    await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
+  })
+})
+
+function questionInput() {
+  return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
+}
+
+function toolRegion(id: string) {
+  return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
+}

+ 272 - 0
packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts

@@ -0,0 +1,272 @@
+import { expect, test } from "@playwright/test"
+import {
+  defineVisualRegions,
+  reportVisualStability,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../../utils/visual-stability"
+import {
+  assistantID,
+  assistantMessage,
+  completedAssistantInfo,
+  event,
+  messageUpdated,
+  partDelta,
+  partUpdated,
+  setupTimeline,
+  shell,
+  status,
+  textPart,
+  toolPart,
+  userMessage,
+} from "./fixture"
+
+test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
+  const firstID = "prt_mutation_01_first"
+  const middleID = "prt_mutation_02_middle"
+  const lastID = "prt_mutation_03_last"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
+        completed: false,
+      }),
+    ],
+    cpuRate: 4,
+  })
+  const regions = defineVisualRegions({
+    first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
+  await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
+  await timeline.send(
+    event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
+    500,
+  )
+  await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
+})
+
+test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
+  const textID = "prt_text_reconcile"
+  const followingID = "prt_text_reconcile_following"
+  const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
+    completed: false,
+  })
+  const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
+  const regions = defineVisualRegions({
+    text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(partDelta(textID, " streamed content"), 100)
+  await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
+  await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
+  await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "text-reconcile",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["text", "following"] },
+      { type: "unique", regions: ["text", "following"] },
+      { type: "stable", regions: ["text", "following"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
+      { type: "label-stability", regions: "all" },
+      { type: "preserve-bottom-anchor" },
+      { type: "flow", regions: ["text", "following"] },
+    ]),
+  )
+})
+
+test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
+  const firstID = "prt_question_01_first"
+  const questionID = "prt_question_02_hidden"
+  const lastID = "prt_question_03_last"
+  const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage(
+        [
+          textPart(firstID, "Before question"),
+          toolPart(questionID, "question", "running", input),
+          textPart(lastID, "After question"),
+        ],
+        { completed: false },
+      ),
+    ],
+    cpuRate: 4,
+  })
+  await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
+  const regions = defineVisualRegions({
+    first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+    last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
+    600,
+  )
+  await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
+})
+
+test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
+  const assistant = assistantMessage([], { completed: false })
+  const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
+  await timeline.send(status("busy"), 150)
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
+  const regions = defineVisualRegions({
+    thinking: { selector: '[data-timeline-row="Thinking"]' },
+    error: { selector: '[data-timeline-row="Error"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    messageUpdated({
+      ...assistant.info,
+      error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
+    }),
+    500,
+  )
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+  await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "thinking-error",
+    trace,
+    visualPlan(regions, [
+      { type: "required", regions: ["thinking", "error"] },
+      { type: "continuous-any", regions: ["thinking", "error"] },
+      { type: "unique", regions: ["thinking", "error"] },
+      { type: "opacity", regions: "all" },
+      { type: "continuity", regions: "all" },
+      { type: "motion", regions: "all" },
+      { type: "label-stability", regions: "all" },
+    ]),
+  )
+})
+
+test("updates retry attempts and long provider messages without remounting the retry row", async ({
+  page,
+}, testInfo) => {
+  const timeline = await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([], { completed: false })],
+    cpuRate: 4,
+  })
+  await timeline.send(status("retry", 1), 120)
+  await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
+  const regions = defineVisualRegions({
+    retry: { selector: '[data-timeline-row="Retry"]' },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    event("session.status", {
+      sessionID: "ses_timeline_stability",
+      status: {
+        type: "retry",
+        attempt: 2,
+        message: "A very long provider retry message ".repeat(8),
+        next: Date.now() + 10_000,
+      },
+    }),
+    300,
+  )
+  await timeline.send(status("retry", 3), 300)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "retry-evolution",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["retry"] },
+        { type: "unique", regions: ["retry"] },
+        { type: "stable", regions: ["retry"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+      ],
+      { perMarker: true },
+    ),
+  )
+})
+
+test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
+  page,
+}, testInfo) => {
+  const removeUserID = "msg_0500_remove_user"
+  const removeAssistantID = "msg_0501_remove_assistant"
+  const anchorUserID = "msg_2000_anchor_user"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
+      assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
+        id: removeAssistantID,
+        parentID: removeUserID,
+        created: 1690000001000,
+      }),
+      userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
+      assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
+        id: "msg_2001_anchor_assistant",
+        parentID: anchorUserID,
+        created: 1700000001000,
+      }),
+    ],
+    cpuRate: 4,
+  })
+  const regions = defineVisualRegions({
+    anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
+  })
+  await startVisualProbe(page, regions)
+  await timeline.send(
+    event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
+    200,
+  )
+  await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
+  const trace = await stopVisualProbe<keyof typeof regions>(page)
+  await reportVisualStability(
+    testInfo,
+    "historical-turn-remove",
+    trace,
+    visualPlan(
+      regions,
+      [
+        { type: "required", regions: ["anchor"] },
+        { type: "unique", regions: ["anchor"] },
+        { type: "stable", regions: ["anchor"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all", maxPositionReversals: 0 },
+        { type: "label-stability", regions: "all" },
+      ],
+      { perMarker: true },
+    ),
+  )
+})
+
+function stablePairPlan(
+  regions: Record<"first" | "last", { selector: string; closest?: string }>,
+  maxPositionReversals: number,
+) {
+  return visualPlan(regions, [
+    { type: "required", regions: ["first", "last"] },
+    { type: "unique", regions: ["first", "last"] },
+    { type: "stable", regions: ["first", "last"] },
+    { type: "opacity", regions: "all" },
+    { type: "continuity", regions: "all" },
+    { type: "motion", regions: "all", maxPositionReversals },
+    { type: "label-stability", regions: "all" },
+  ])
+}

+ 392 - 0
packages/app/e2e/performance/unit/visual-stability.test.ts

@@ -0,0 +1,392 @@
+import { expect, test } from "bun:test"
+import {
+  analyzeVisualStability,
+  analyzeVisualStabilityByMarker,
+  type VisualStabilityTrace,
+} from "../../utils/visual-stability"
+import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
+import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
+import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
+
+function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
+  return { markers: [], samples }
+}
+
+test("accepts continuous visible motion", () => {
+  expect(
+    analyzeVisualStability(
+      trace([
+        frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
+        frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
+        frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
+      ]),
+      { flow: ["changing", "following"] },
+    ),
+  ).toEqual([])
+})
+
+test("reports repeated geometry reversals", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      frame(0, region({ width: 80 })),
+      frame(16, region({ width: 60 })),
+      frame(32, region({ width: 78 })),
+      frame(48, region({ width: 62 })),
+    ]),
+  )
+
+  expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
+})
+
+test("reports visible blanking, label reversal, and overlap", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
+      frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
+      frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
+    ]),
+    { flow: ["changing", "following"] },
+  )
+
+  expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
+  expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
+  expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
+})
+
+test("reports duplicate regions and unexpected remounts", () => {
+  const issues = analyzeVisualStability(
+    trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
+    { stable: ["changing"], unique: ["changing"] },
+  )
+
+  expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
+  expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
+})
+
+test("reports bottom anchor loss but permits movement while scrolled away", () => {
+  const anchored = analyzeVisualStability(
+    trace([
+      { at: 0, regions: { changing: region() }, viewport: viewport(0) },
+      { at: 16, regions: { changing: region() }, viewport: viewport(24) },
+    ]),
+    { preserveBottomAnchor: true },
+  )
+  const away = analyzeVisualStability(
+    trace([
+      { at: 0, regions: { changing: region() }, viewport: viewport(80) },
+      { at: 16, regions: { changing: region() }, viewport: viewport(104) },
+    ]),
+    { preserveBottomAnchor: true },
+  )
+
+  expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
+  expect(away).toEqual([])
+})
+
+test("reports up down up movement while preserving a bottom anchor", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
+      { at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
+      { at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
+      { at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
+    ]),
+    { preserveBottomAnchor: true, maxPositionReversals: 0 },
+  )
+
+  expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
+  expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
+})
+
+test("accepts monotonic upward movement while preserving a bottom anchor", () => {
+  expect(
+    analyzeVisualStability(
+      trace([
+        { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
+        { at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
+        { at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
+      ]),
+      { preserveBottomAnchor: true, maxPositionReversals: 0 },
+    ),
+  ).toEqual([])
+})
+
+test("ignores overlap entirely outside the clipped timeline viewport", () => {
+  expect(
+    analyzeVisualStability(
+      trace([
+        {
+          at: 0,
+          regions: {
+            changing: region({ top: -200, bottom: -100 }),
+            following: region({ top: -150, bottom: -50 }),
+          },
+          viewport: viewport(0),
+        },
+      ]),
+      { flow: ["changing", "following"] },
+    ),
+  ).toEqual([])
+})
+
+test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
+      { at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
+    ]),
+    { fixed: ["anchor"] },
+  )
+
+  expect(issues).toEqual([])
+
+  const moved = analyzeVisualStability(
+    trace([
+      { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
+      { at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
+    ]),
+    { fixed: ["anchor"] },
+  )
+  expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
+})
+
+test("analyzes each marked event independently", () => {
+  const input: VisualStabilityTrace = {
+    markers: [
+      { at: 10, label: "grow" },
+      { at: 40, label: "shrink" },
+    ],
+    samples: [
+      frame(0, region({ top: 100 })),
+      frame(16, region({ top: 90 })),
+      frame(32, region({ top: 80 })),
+      frame(48, region({ top: 90 })),
+      frame(64, region({ top: 100 })),
+    ],
+  }
+
+  expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
+  expect(
+    analyzeVisualStabilityByMarker(input, {
+      maxPositionReversals: 0,
+      motion: ["changing"],
+      aggregateMotion: false,
+    }),
+  ).toEqual([])
+})
+
+test("reports cross-event motion reversals by default", () => {
+  const input: VisualStabilityTrace = {
+    markers: [
+      { at: 10, label: "up" },
+      { at: 40, label: "down" },
+    ],
+    samples: [
+      frame(0, region({ top: 100 })),
+      frame(16, region({ top: 90 })),
+      frame(32, region({ top: 80 })),
+      frame(48, region({ top: 90 })),
+      frame(64, region({ top: 100 })),
+    ],
+  }
+
+  expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
+})
+
+test("reports regions rendered in the wrong flow order", () => {
+  const issues = analyzeVisualStability(
+    trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
+    { flow: ["changing", "following"] },
+  )
+
+  expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
+})
+
+test("uses painted bounds instead of clipped layout overflow", () => {
+  expect(
+    analyzeVisualStability(
+      trace([
+        frame(
+          0,
+          region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
+          region({ top: 140, bottom: 180 }),
+        ),
+      ]),
+      { flow: ["changing", "following"] },
+    ),
+  ).toEqual([])
+})
+
+test("does not report disappearance when a present row moves outside the viewport", () => {
+  expect(
+    analyzeVisualStability(
+      trace([
+        frame(0, region({ visible: true })),
+        frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
+        frame(32, region({ visible: true })),
+      ]),
+    ),
+  ).toEqual([])
+})
+
+test("reports an in-viewport transparent frame between visible frames", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      frame(0, region()),
+      frame(16, region({ visible: false, opacity: 0, inViewport: true })),
+      frame(32, region()),
+    ]),
+  )
+
+  expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
+})
+
+test("reports an in-viewport display-none frame between visible frames", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      frame(0, region()),
+      frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
+      frame(32, region()),
+    ]),
+  )
+
+  expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
+})
+
+test("can limit motion analysis to unaffected regions", () => {
+  expect(
+    analyzeVisualStability(
+      trace([
+        frame(0, region({ height: 20 }), region()),
+        frame(16, region({ height: 40 }), region()),
+        frame(32, region({ height: 30 }), region()),
+      ]),
+      { motion: ["following"] },
+    ),
+  ).toEqual([])
+})
+
+test("reports a blank frame across replacement surfaces", () => {
+  const issues = analyzeVisualStability(
+    {
+      markers: [],
+      samples: [
+        { at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
+        {
+          at: 16,
+          regions: {
+            thinking: region({ present: false, visible: false }),
+            error: region({ present: false, visible: false }),
+          },
+        },
+        { at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
+      ],
+    },
+    { continuousAny: [["thinking", "error"]] },
+  )
+
+  expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
+})
+
+test("reports failure to acquire the bottom anchor", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      { at: 0, regions: { changing: region() }, viewport: viewport(600) },
+      { at: 16, regions: { changing: region() }, viewport: viewport(120) },
+    ]),
+    { acquireBottomAnchor: true },
+  )
+
+  expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
+})
+
+test("reports a required region that never renders", () => {
+  const issues = analyzeVisualStability(
+    trace([
+      {
+        at: 0,
+        regions: { changing: region({ present: false, visible: false }) },
+      },
+    ]),
+    { required: ["changing"] },
+  )
+
+  expect(issues).toContain("changing never rendered")
+})
+
+test("preserves typed region names while mapping definitions", () => {
+  const regions = defineVisualRegions({
+    changing: { selector: "[data-changing]" },
+    following: { selector: "[data-following]", closest: "[data-row]" },
+  })
+  const selectors = mapVisualRegions(regions, (region) => region.selector)
+
+  expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
+  const name: keyof typeof selectors = "changing"
+  expect(name).toBe("changing")
+})
+
+test("evaluates the typed invariant algebra over explicit observations", () => {
+  const regions = defineVisualRegions({
+    changing: { selector: "[data-changing]" },
+    following: { selector: "[data-following]" },
+  })
+  const invariants = [
+    { type: "required", regions: ["changing"] },
+    { type: "flow", regions: ["changing", "following"] },
+  ] satisfies VisualInvariant<keyof typeof regions>[]
+  // @ts-expect-error Plans reject names that are not in the region definition.
+  const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
+  const plan = visualPlan(regions, invariants, { perMarker: true })
+
+  expect(invalid.regions).toEqual(["missing"])
+  expect(plan.perMarker).toBe(true)
+  expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
+    "changing overlapped following by 1px at 0ms",
+  ])
+})
+
+test("legacy plan adapter preserves analyzer messages and order", () => {
+  const input = trace([
+    frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
+    frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
+    frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
+  ])
+  const options = { flow: ["changing", "following"], stable: ["changing"] }
+
+  expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
+    analyzeVisualStability(input, options),
+  )
+})
+
+function frame(
+  at: number,
+  changing: VisualStabilityTrace["samples"][number]["regions"][string],
+  following?: VisualStabilityTrace["samples"][number]["regions"][string],
+) {
+  return { at, regions: { changing, ...(following ? { following } : {}) } }
+}
+
+function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
+  return {
+    present: true,
+    visible: true,
+    inViewport: true,
+    top: 0,
+    bottom: 20,
+    width: 100,
+    height: 20,
+    opacity: 1,
+    count: 1,
+    node: 1,
+    label: "",
+    text: "",
+    layoutTop: input.top ?? 0,
+    layoutBottom: input.bottom ?? 20,
+    ...input,
+  }
+}
+
+function viewport(distanceFromBottom: number) {
+  return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
+}

+ 22 - 0
packages/app/e2e/regression/session-timeline-accessibility.spec.ts

@@ -0,0 +1,22 @@
+import { expect, test } from "@playwright/test"
+import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
+
+test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
+  const shellID = "prt_space_button_shell"
+  await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
+    settings: { shellToolPartsExpanded: false },
+    reducedMotion: true,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
+  await trigger.focus()
+  const before = await scroller.evaluate((element) => element.scrollTop)
+  await trigger.press("Space")
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
+})
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}

+ 116 - 9
packages/app/e2e/regression/session-timeline-context-resize.spec.ts

@@ -1,6 +1,13 @@
 import { expect, test, type Page } from "@playwright/test"
 import { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { expectAppVisible, expectSessionTitle } from "../utils/waits"
 import { expectAppVisible, expectSessionTitle } from "../utils/waits"
+import {
+  analyzeVisualObservations,
+  defineVisualRegions,
+  startVisualProbe,
+  stopVisualProbe,
+  visualPlan,
+} from "../utils/visual-stability"
 
 
 const directory = "C:/OpenCode/ContextResizeRegression"
 const directory = "C:/OpenCode/ContextResizeRegression"
 const projectID = "proj_context_resize_regression"
 const projectID = "proj_context_resize_regression"
@@ -36,6 +43,82 @@ test.describe("regression: session timeline context group resize", () => {
     expect(visibleOverlap).toEqual([])
     expect(visibleOverlap).toEqual([])
     expect(samples.at(-1)?.expanded).toBe("true")
     expect(samples.at(-1)?.expanded).toBe("true")
   })
   })
+
+  test("paints a stable exploring to explored transition", async ({ page }) => {
+    const events: { directory: string; payload: Record<string, unknown> }[] = []
+    await page.setViewportSize({ width: 1400, height: 900 })
+    await mockServer(page, events, [
+      ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
+      ...turn(10, true, "running"),
+    ])
+    await configurePage(page)
+
+    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+    await expectSessionTitle(page, title)
+    const devtools = await page.context().newCDPSession(page)
+    await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
+    const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
+    await expectAppVisible(context)
+    await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
+
+    const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
+    const regions = defineVisualRegions({
+      status: {
+        selector: `${contextSelector} [data-component="tool-status-title"]`,
+        opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
+      },
+      context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
+      following: {
+        selector: `[data-timeline-part-id="${followingTextID}"]`,
+        closest: '[data-timeline-row="AssistantPart"]',
+      },
+    })
+    await startVisualProbe(page, regions)
+    for (const [index, delay] of [120, 350, 80, 500].entries()) {
+      events.push({
+        directory,
+        payload: {
+          type: "message.part.updated",
+          properties: {
+            part: contextTool(
+              contextIDs[index]!,
+              id("msg_assistant", 10),
+              ["read", "glob", "grep", "list"][index]!,
+              [
+                { filePath: "src/recent-a.ts" },
+                { path: directory, pattern: "**/*.ts" },
+                { path: directory, pattern: "Explored" },
+                { path: "src" },
+              ][index]!,
+            ),
+          },
+        },
+      })
+      await page.waitForTimeout(delay)
+    }
+
+    await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
+    await page.waitForTimeout(700)
+    const trace = await stopVisualProbe<keyof typeof regions>(page)
+    const labels = trace.samples
+      .map((sample) => sample.regions.status?.label)
+      .filter((value): value is string => !!value)
+      .filter((value, index, all) => value !== all[index - 1])
+    const issues = analyzeVisualObservations(
+      trace.samples,
+      visualPlan(regions, [
+        { type: "required", regions: ["context", "following"] },
+        { type: "opacity", regions: "all" },
+        { type: "continuity", regions: "all" },
+        { type: "motion", regions: "all" },
+        { type: "label-stability", regions: "all" },
+        { type: "flow", regions: ["context", "following"] },
+      ]),
+    )
+
+    expect(labels).toEqual(["Exploring", "Explored"])
+    expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
+  })
 })
 })
 
 
 async function configurePage(page: Page) {
 async function configurePage(page: Page) {
@@ -128,7 +211,7 @@ async function sampleExpansion(page: Page) {
   )
   )
 }
 }
 
 
-function turn(index: number, target: boolean): Message[] {
+function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
   const userID = id("msg_user", index)
   const userID = id("msg_user", index)
   const assistantID = id("msg_assistant", index)
   const assistantID = id("msg_assistant", index)
   return [
   return [
@@ -163,10 +246,22 @@ function turn(index: number, target: boolean): Message[] {
       },
       },
       parts: target
       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" }),
+            contextTool(
+              contextIDs[0]!,
+              assistantID,
+              "read",
+              { filePath: "src/recent-a.ts", offset: 0, limit: 120 },
+              status,
+            ),
+            contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
+            contextTool(
+              contextIDs[2]!,
+              assistantID,
+              "grep",
+              { path: directory, pattern: "Explored", include: "*.ts" },
+              status,
+            ),
+            contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
             {
             {
               id: followingTextID,
               id: followingTextID,
               sessionID,
               sessionID,
@@ -188,7 +283,13 @@ function turn(index: number, target: boolean): Message[] {
   ]
   ]
 }
 }
 
 
-function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
+function contextTool(
+  partID: string,
+  messageID: string,
+  tool: string,
+  input: Record<string, unknown>,
+  status: "running" | "completed" = "completed",
+) {
   return {
   return {
     id: partID,
     id: partID,
     sessionID,
     sessionID,
@@ -197,7 +298,7 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
     callID: `call_${partID}`,
     callID: `call_${partID}`,
     tool,
     tool,
     state: {
     state: {
-      status: "completed",
+      status,
       input,
       input,
       output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
       output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
       title: input.filePath || input.path || input.pattern || "completed",
       title: input.filePath || input.path || input.pattern || "completed",
@@ -207,13 +308,19 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
   }
   }
 }
 }
 
 
-async function mockServer(page: Page) {
+async function mockServer(
+  page: Page,
+  events: { directory: string; payload: Record<string, unknown> }[] = [],
+  fixtureMessages = messages,
+) {
   await mockOpenCodeServer(page, {
   await mockOpenCodeServer(page, {
     directory,
     directory,
     project: project(),
     project: project(),
     provider: provider(),
     provider: provider(),
     sessions: [session()],
     sessions: [session()],
-    pageMessages: () => ({ items: messages }),
+    pageMessages: () => ({ items: fixtureMessages }),
+    events: () => events.splice(0, 1),
+    eventRetry: 50,
   })
   })
 }
 }
 
 

+ 31 - 0
packages/app/e2e/regression/session-timeline-context-state.spec.ts

@@ -0,0 +1,31 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+test("preserves a collapsed context group through count and status updates", async ({ page }) => {
+  const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
+  const inputs = {
+    read: { filePath: "src/a.ts", offset: 0, limit: 120 },
+    glob: { path: ".", pattern: "**/*.ts" },
+  }
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage(
+        [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
+        { completed: false },
+      ),
+    ],
+  })
+  const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
+  const trigger = group.locator('[data-slot="collapsible-trigger"]')
+  await expect(trigger).toHaveAttribute("aria-expanded", "false")
+  await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
+  await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
+  await expect(trigger).toHaveAttribute("aria-expanded", "false")
+})

+ 52 - 0
packages/app/e2e/regression/session-timeline-file-projection.spec.ts

@@ -0,0 +1,52 @@
+import { expect, test } from "@playwright/test"
+import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
+
+test("renders completed write content", async ({ page }) => {
+  const id = "prt_file_projection_write"
+  await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
+      ]),
+    ],
+    settings: { editToolPartsExpanded: true },
+  })
+
+  await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
+})
+
+test("renders a completed single-file patch", async ({ page }) => {
+  const id = "prt_file_projection_single_patch"
+  await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(
+          id,
+          "apply_patch",
+          "completed",
+          { files: ["src/a.ts"] },
+          {
+            metadata: {
+              files: [
+                {
+                  filePath: "src/a.ts",
+                  relativePath: "src/a.ts",
+                  type: "update",
+                  additions: 1,
+                  deletions: 1,
+                  before: "export const value = 1\n",
+                  after: "export const value = 2\n",
+                },
+              ],
+            },
+          },
+        ),
+      ]),
+    ],
+    settings: { editToolPartsExpanded: true },
+  })
+
+  await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
+})

+ 94 - 0
packages/app/e2e/regression/session-timeline-file-state.spec.ts

@@ -0,0 +1,94 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
+  const editID = "prt_diagnostics_edit"
+  const base = editPart(editID, [])
+  const timeline = await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([base])],
+    settings: { editToolPartsExpanded: true },
+  })
+  const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
+  await trigger.click()
+  await expect(trigger).toHaveAttribute("aria-expanded", "false")
+  await timeline.send(
+    partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
+    300,
+  )
+  await expect(trigger).toHaveAttribute("aria-expanded", "false")
+  await timeline.send(partUpdated(editPart(editID, [])), 300)
+  await expect(trigger).toHaveAttribute("aria-expanded", "false")
+})
+
+test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
+  const patchID = "prt_nested_patch"
+  const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
+  await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(
+          patchID,
+          "apply_patch",
+          "completed",
+          { files: files.map((file) => file.filePath) },
+          { metadata: { files } },
+        ),
+      ]),
+    ],
+    settings: { editToolPartsExpanded: true },
+  })
+  const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
+  const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
+  const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
+  await deleted.getByRole("button").click()
+  await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
+  await outer.click()
+  await expect(outer).toHaveAttribute("aria-expanded", "false")
+  await outer.click()
+  await expect(outer).toHaveAttribute("aria-expanded", "true")
+  await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
+})
+
+function patchFile(filePath: string, type: "add" | "update" | "delete") {
+  return {
+    filePath,
+    relativePath: filePath,
+    type,
+    additions: type === "delete" ? 0 : 4,
+    deletions: type === "add" ? 0 : 3,
+    before: type === "add" ? undefined : source(false),
+    after: type === "delete" ? undefined : source(true),
+  }
+}
+
+function editPart(id: string, diagnostics: Record<string, unknown>[]) {
+  return toolPart(
+    id,
+    "edit",
+    "completed",
+    { filePath: "src/edit.ts" },
+    {
+      metadata: {
+        filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
+        diagnostics,
+      },
+    },
+  )
+}
+
+function diagnostic(message: string, line: number) {
+  return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
+}
+
+function source(changed: boolean) {
+  return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
+    "",
+  )
+}

+ 94 - 0
packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts

@@ -0,0 +1,94 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  completedAssistantInfo,
+  messageUpdated,
+  partUpdated,
+  reasoningPart,
+  setupTimeline,
+  shell,
+  status,
+  textPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+for (const expanded of [false, true]) {
+  test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
+    const id = `prt_shell_default_${expanded}`
+    const timeline = await setupTimeline(page, {
+      messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
+      settings: { shellToolPartsExpanded: expanded },
+    })
+    const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
+    await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
+    await trigger.click()
+    await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
+
+    await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
+    await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
+    await timeline.send(status("busy"), 100)
+    await timeline.send(status("idle"), 250)
+    await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
+  })
+}
+
+test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
+  const reasoningID = "prt_reasoning_hidden"
+  const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
+  const timeline = await setupTimeline(page, {
+    messages: [userMessage(), assistant],
+    settings: { showReasoningSummaries: false },
+    cpuRate: 4,
+  })
+  await timeline.send(status("busy"), 150)
+
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
+  await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
+  await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
+  await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
+  await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
+  await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
+  await timeline.send(status("idle"), 300)
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+  await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
+})
+
+test("moves busy through retry and recovery to final idle content", async ({ page }) => {
+  const assistant = assistantMessage([], { completed: false })
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(undefined, {
+        summary: {
+          diffs: [
+            {
+              file: "src/retry.ts",
+              additions: 1,
+              deletions: 1,
+              patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
+            },
+          ],
+        },
+      }),
+      assistant,
+    ],
+  })
+  await timeline.send(status("busy"), 140)
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
+  await timeline.send(status("retry"), 180)
+  await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+  await timeline.send(status("busy", 2), 180)
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
+  await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
+  await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
+  await timeline.send(status("idle"), 350)
+  await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+  await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
+})
+
+function lines(count: number) {
+  return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
+}

+ 25 - 0
packages/app/e2e/regression/session-timeline-locale-projection.spec.ts

@@ -0,0 +1,25 @@
+import { expect, test } from "@playwright/test"
+import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
+
+for (const profile of [
+  { locale: "de", label: "Erkundet" },
+  { locale: "ar", label: "تم الاستكشاف" },
+] as const) {
+  test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
+    const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
+    await setupTimeline(page, {
+      messages: [
+        userMessage(),
+        assistantMessage([
+          toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
+          toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
+        ]),
+      ],
+      locale: profile.locale,
+    })
+
+    const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
+    await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
+    await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
+  })
+}

+ 284 - 0
packages/app/e2e/regression/session-timeline-projection.spec.ts

@@ -0,0 +1,284 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  setupTimeline,
+  status,
+  toolPart,
+  userMessage,
+  userText,
+  type PartSeed,
+} from "../performance/timeline-stability/fixture"
+
+test.describe("session timeline projection", () => {
+  test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
+    const parts = [
+      toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
+      toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
+      toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
+      toolPart("prt_04_list", "list", "completed", { path: "src" }),
+      toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
+      toolPart(
+        "prt_websearch",
+        "websearch",
+        "completed",
+        { query: "timeline stability" },
+        { output: "https://example.com/result" },
+      ),
+      toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
+      toolPart(
+        "prt_bash",
+        "bash",
+        "completed",
+        { command: "printf stable" },
+        { output: "stable", title: "printf stable" },
+      ),
+      editPart("prt_edit"),
+      toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
+      patchPart("prt_patch"),
+      toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
+      toolPart(
+        "prt_question",
+        "question",
+        "completed",
+        { questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
+        { metadata: { answers: [["Yes"]] } },
+      ),
+      toolPart("prt_skill", "skill", "completed", { name: "stability" }),
+      toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
+    ]
+    await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
+
+    await expect(
+      page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
+    ).toBeVisible()
+    for (const id of [
+      "prt_webfetch",
+      "prt_websearch",
+      "prt_task",
+      "prt_bash",
+      "prt_edit",
+      "prt_write",
+      "prt_patch",
+      "prt_question",
+      "prt_skill",
+      "prt_custom",
+    ]) {
+      await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
+    }
+    await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
+  })
+
+  test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
+    const firstUser = userMessage(
+      [
+        userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
+          id: "prt_comment",
+          synthetic: true,
+          metadata: {
+            opencodeComment: {
+              path: "src/a.ts",
+              selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
+              comment: "Keep this stable",
+            },
+          },
+        }),
+        userText("Continue after the comment", { id: "prt_visible_user" }),
+      ],
+      { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
+    )
+    const aborted = assistantMessage(
+      [
+        { id: "prt_before_abort", type: "text", text: "Before interruption" },
+        { id: "prt_compaction", type: "compaction", auto: true },
+      ],
+      {
+        id: "msg_1001_assistant_aborted",
+        error: { name: "MessageAbortedError", data: { message: "Stopped" } },
+      },
+    )
+    const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
+      id: "msg_1002_assistant_failed",
+      error: {
+        name: "APIError",
+        data: {
+          message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
+          isRetryable: false,
+        },
+      },
+      created: 1700000003000,
+    })
+    const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
+      id: "msg_2000_second_user",
+      created: 1700000005000,
+    })
+    const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
+      id: "msg_2001_second_assistant",
+      parentID: "msg_2000_second_user",
+      created: 1700000006000,
+    })
+    const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
+    await timeline.send(status("idle"), 100)
+    const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+    await scroller.evaluate((element) => (element.scrollTop = 0))
+
+    await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
+    await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
+    await expect(page.getByText("Visible provider failure")).toBeVisible()
+    await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
+    await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
+  })
+
+  test("renders comment strips and historical diff summary overflow", async ({ page }) => {
+    const user = userMessage(
+      [
+        userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
+          id: "prt_comment_only",
+          synthetic: true,
+          metadata: {
+            opencodeComment: {
+              path: "src/a.ts",
+              selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
+              comment: "Keep this stable",
+            },
+          },
+        }),
+        userText("Continue after the comment", { id: "prt_comment_visible" }),
+      ],
+      { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
+    )
+    const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
+    const nextAssistant = assistantMessage([], {
+      id: "msg_2001_diff_next_assistant",
+      parentID: "msg_2000_diff_next_user",
+      created: 1700000011000,
+    })
+    await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
+    const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+    await scroller.evaluate((element) => (element.scrollTop = 0))
+
+    await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
+    await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
+    await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
+    await expect(page.getByText(/show all/i)).toBeVisible()
+  })
+
+  test("renders interruption independently when the turn is not compacted", async ({ page }) => {
+    const user = userMessage()
+    const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
+      id: "msg_1001_before",
+      error: { name: "MessageAbortedError", data: { message: "Stopped" } },
+    })
+    const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
+      id: "msg_1002_after",
+      created: 1700000003000,
+    })
+    await setupTimeline(page, { messages: [user, before, after] })
+
+    await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
+    const rows = await page
+      .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
+      .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
+    expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
+  })
+
+  test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
+    const text = "Use @explore with @src/a.ts and inspect the attachments"
+    const parts: PartSeed<"user">[] = [
+      userText(text, { id: "prt_user_rich" }),
+      {
+        id: "prt_user_image",
+        type: "file",
+        mime: "image/png",
+        filename: "pixel.png",
+        url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+      },
+      {
+        id: "prt_user_attachment",
+        type: "file",
+        mime: "application/json",
+        filename: "tsconfig.json",
+        url: "data:application/json;base64,e30=",
+      },
+      {
+        id: "prt_user_reference",
+        type: "file",
+        mime: "text/plain",
+        filename: "a.ts",
+        url: "src/a.ts",
+        source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
+      },
+      {
+        id: "prt_user_agent",
+        type: "agent",
+        name: "explore",
+        source: { value: "@explore", start: 4, end: 12 },
+      },
+    ]
+    await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
+
+    await expect(page.getByAltText("pixel.png")).toBeVisible()
+    await expect(page.getByText("tsconfig.json")).toBeVisible()
+    await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
+    await expect(page.getByText("@explore", { exact: true })).toBeVisible()
+  })
+})
+
+function editPart(id: string) {
+  return toolPart(
+    id,
+    "edit",
+    "completed",
+    { filePath: "src/a.ts" },
+    {
+      metadata: {
+        filediff: {
+          file: "src/a.ts",
+          additions: 1,
+          deletions: 1,
+          before: "export const value = 1\n",
+          after: "export const value = 2\n",
+        },
+      },
+    },
+  )
+}
+
+function patchPart(id: string) {
+  return toolPart(
+    id,
+    "apply_patch",
+    "completed",
+    { files: ["src/a.ts", "src/b.ts"] },
+    {
+      metadata: {
+        files: [
+          patchFile("src/a.ts", "update"),
+          patchFile("src/b.ts", "add"),
+          patchFile("src/old.ts", "delete"),
+          { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
+        ],
+      },
+    },
+  )
+}
+
+function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
+  return {
+    filePath,
+    relativePath: filePath,
+    type,
+    additions: type === "delete" ? 0 : 1,
+    deletions: type === "add" ? 0 : 1,
+    before: type === "add" ? undefined : "export const before = true\n",
+    after: type === "delete" ? undefined : "export const after = true\n",
+  }
+}
+
+function summaryDiff(index: number) {
+  return {
+    file: `src/diff-${index}.ts`,
+    additions: 1,
+    deletions: 1,
+    patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
+  }
+}

+ 93 - 0
packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts

@@ -0,0 +1,93 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  reasoningPart,
+  setupTimeline,
+  status,
+  textPart,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+const profiles = [
+  { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
+  {
+    name: "summaries off reasoning heading",
+    summaries: false,
+    reasoning: "## Inspecting stability",
+    other: false,
+    thinking: true,
+    body: false,
+  },
+  {
+    name: "summaries off with visible tool",
+    summaries: false,
+    reasoning: "## Inspecting stability",
+    other: true,
+    thinking: true,
+    body: false,
+  },
+  { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
+  {
+    name: "summaries on blank reasoning",
+    summaries: true,
+    reasoning: "   ",
+    other: false,
+    thinking: true,
+    body: false,
+  },
+  {
+    name: "summaries on visible reasoning",
+    summaries: true,
+    reasoning: "## Inspecting stability",
+    other: false,
+    thinking: false,
+    body: true,
+  },
+  {
+    name: "summaries on visible tool no reasoning",
+    summaries: true,
+    reasoning: "",
+    other: true,
+    thinking: false,
+    body: false,
+  },
+] as const
+
+for (const profile of profiles) {
+  test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
+    const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
+    const parts = [
+      ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
+      ...(profile.other
+        ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
+        : []),
+    ]
+    const timeline = await setupTimeline(page, {
+      messages: [userMessage(), assistantMessage(parts, { completed: false })],
+      settings: { showReasoningSummaries: profile.summaries },
+    })
+    await timeline.send(status("busy"), 150)
+
+    await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
+    await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
+    if (!profile.summaries && profile.reasoning.trim()) {
+      await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
+    }
+  })
+}
+
+test("does not infer reasoning visibility from provider identity", async ({ page }) => {
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
+    ],
+    settings: { showReasoningSummaries: true },
+  })
+  await timeline.send(status("busy"), 150)
+
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+  await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
+  await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
+})

+ 43 - 0
packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts

@@ -0,0 +1,43 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  completedAssistantInfo,
+  messageUpdated,
+  partUpdated,
+  setupTimeline,
+  shell,
+  status,
+  textPart,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
+  const parts = [
+    toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
+    textPart("prt_boundary_02_text", "Boundary text"),
+    toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
+    toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
+    shell("prt_boundary_05_shell", "completed", "done"),
+    toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
+  ]
+  await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
+
+  await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
+})
+
+test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
+  const textID = "prt_event_order_text"
+  const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
+  const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
+  await timeline.send(status("busy"), 100)
+  await timeline.send(status("idle"), 100)
+  await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
+  await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
+
+  await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
+  await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
+})

+ 99 - 0
packages/app/e2e/regression/session-timeline-tool-projection.spec.ts

@@ -0,0 +1,99 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
+  const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
+  const parts = ordinary.map((tool, index) =>
+    toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
+  )
+  parts.push(
+    toolPart("prt_question_dismissed", "question", "error", questionInput(), {
+      error: "The user dismissed this question",
+    }),
+    toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
+    toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
+  )
+  await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
+
+  await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
+  await expect(page.getByText(/dismissed/i)).toBeVisible()
+  await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
+  for (let index = 0; index < ordinary.length; index++) {
+    await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
+  }
+})
+
+test("transitions shell and question through running error outcomes", async ({ page }) => {
+  const shellID = "prt_transition_error_shell"
+  const questionID = "prt_transition_error_question"
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage(
+        [
+          toolPart(shellID, "bash", "pending", { command: "exit 1" }),
+          toolPart(questionID, "question", "pending", questionInput()),
+        ],
+        { completed: false },
+      ),
+    ],
+  })
+  await timeline.waitForPart(shellID)
+  await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
+  await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
+  await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
+  await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
+  await timeline.send(
+    partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
+    180,
+  )
+  await timeline.send(
+    partUpdated(
+      toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
+    ),
+    250,
+  )
+
+  await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
+  await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
+})
+
+test("labels all web search provider variants", async ({ page }) => {
+  const parts = [
+    toolPart(
+      "prt_search_parallel",
+      "websearch",
+      "completed",
+      { query: "parallel" },
+      { metadata: { provider: "parallel" } },
+    ),
+    toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
+    toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
+  ]
+  await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
+
+  await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
+  await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
+  await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
+})
+
+function questionInput() {
+  return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
+}
+
+function errorInput(tool: string) {
+  if (tool === "bash") return { command: "exit 1" }
+  if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
+  if (tool === "apply_patch") return { files: ["src/error.ts"] }
+  if (tool === "webfetch") return { url: "https://example.com" }
+  if (tool === "websearch") return { query: "failure" }
+  if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
+  if (tool === "skill") return { name: "failure" }
+  return { target: "failure" }
+}

+ 77 - 0
packages/app/e2e/regression/session-timeline-tool-state.spec.ts

@@ -0,0 +1,77 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+test("updates expanded web search links without resetting expansion", async ({ page }) => {
+  const searchID = "prt_websearch_mutation"
+  const input = { query: "timeline stability" }
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
+    ],
+  })
+  const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
+  const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
+  await trigger.click()
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await timeline.send(
+    partUpdated(
+      toolPart(searchID, "websearch", "completed", input, {
+        output: "https://example.com/one\nhttps://example.com/two",
+      }),
+    ),
+    300,
+  )
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
+})
+
+test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
+  const toolID = "prt_duplicate_error"
+  const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
+  const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
+  const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
+  const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
+  await trigger.click()
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await timeline.send(partUpdated(failed), 150)
+  await timeline.send(partUpdated(failed), 250)
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await expect(wrapper).toContainText("Command failed visibly")
+})
+
+test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
+  const questionID = "prt_multi_question"
+  const input = {
+    questions: [
+      { header: "First", question: "First choice?", options: [] },
+      { header: "Second", question: "Second choice?", options: [], multiple: true },
+    ],
+  }
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
+      ]),
+    ],
+  })
+  const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
+  const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await timeline.send(
+    partUpdated(
+      toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
+    ),
+    300,
+  )
+  await expect(trigger).toHaveAttribute("aria-expanded", "true")
+  await expect(wrapper).toContainText("Updated")
+  await expect(wrapper).toContainText("B, C")
+})

+ 116 - 0
packages/app/e2e/regression/session-timeline-transport.spec.ts

@@ -0,0 +1,116 @@
+import { expect, test } from "@playwright/test"
+import {
+  assistantMessage,
+  partUpdated,
+  setupTimeline,
+  status,
+  textPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+test("keeps one connection open while delivering multiple events", async ({ page }) => {
+  const timeline = await setupTimeline(page)
+
+  const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
+  const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
+
+  await timeline.waitForPart("prt_transport_first")
+  await timeline.waitForPart("prt_transport_second")
+  expect(first.connectionID).toBe(second.connectionID)
+  expect(await timeline.transport.connections()).toHaveLength(1)
+  expect(await timeline.transport.acknowledgements()).toHaveLength(2)
+})
+
+test("delivers a burst from one stream chunk", async ({ page }) => {
+  const timeline = await setupTimeline(page)
+  const acknowledgements = await timeline.transport.burst([
+    partUpdated(textPart("prt_transport_burst_a", "burst a")),
+    partUpdated(textPart("prt_transport_burst_b", "burst b")),
+  ])
+
+  await timeline.waitForPart("prt_transport_burst_a")
+  await timeline.waitForPart("prt_transport_burst_b")
+  expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
+  expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
+})
+
+test("parses split JSON and a split multibyte code point", async ({ page }) => {
+  const timeline = await setupTimeline(page)
+  const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
+  const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
+  const snowman = new TextEncoder().encode("\u2603")[0]!
+  const multibyte = encoded.indexOf(snowman)
+
+  const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
+
+  await timeline.waitForPart("prt_transport_split")
+  await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
+    "split snowman \u2603\u2603\u2603",
+  )
+  expect(acknowledgement.chunkCount).toBe(4)
+})
+
+test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
+  const timeline = await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
+  })
+  const before = await page.locator("[data-timeline-row]").allTextContents()
+
+  await timeline.transport.heartbeat()
+  await timeline.settle()
+
+  expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
+  expect(await timeline.transport.connections()).toHaveLength(1)
+})
+
+test("reconnects after a clean close", async ({ page }) => {
+  const timeline = await setupTimeline(page, { eventRetry: 10 })
+  const first = await timeline.transport.waitForConnection()
+
+  await timeline.transport.close()
+  const second = await timeline.transport.waitForConnection({ after: first.id })
+  await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
+
+  await timeline.waitForPart("prt_transport_close")
+  expect(second.id).toBeGreaterThan(first.id)
+  expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
+})
+
+test("reconnects after a stream error", async ({ page }) => {
+  const timeline = await setupTimeline(page, { eventRetry: 10 })
+  const first = await timeline.transport.waitForConnection()
+
+  await timeline.transport.error("contract failure")
+  const second = await timeline.transport.waitForConnection({ after: first.id })
+  await timeline.transport.send(status("busy"))
+
+  await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
+  expect(second.id).toBeGreaterThan(first.id)
+  expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
+})
+
+test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
+  const timeline = await setupTimeline(page, { eventRetry: 10 })
+  const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
+    id: "timeline-event-7",
+  })
+  await timeline.waitForPart("prt_transport_id")
+
+  await timeline.transport.error("retry with event id")
+  const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
+
+  expect(first.eventID).toBe("timeline-event-7")
+  expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
+})
+
+test("passes through non-event fetches", async ({ page }) => {
+  const timeline = await setupTimeline(page)
+
+  const health = await page.evaluate(async () => {
+    const response = await fetch("/global/health")
+    return response.json()
+  })
+
+  expect(health).toEqual({ healthy: true })
+  expect(await timeline.transport.connections()).toHaveLength(1)
+})

+ 8 - 1
packages/app/e2e/tsconfig.json

@@ -5,5 +5,12 @@
     "rootDir": "..",
     "rootDir": "..",
     "types": ["node", "bun"]
     "types": ["node", "bun"]
   },
   },
-  "include": ["./**/*.ts"]
+  "include": [
+    "./performance/timeline-stability/**/*.spec.ts",
+    "./performance/timeline-stability/fixture.test.ts",
+    "./performance/timeline-stability/fixture.ts",
+    "./performance/unit/visual-stability.test.ts",
+    "./regression/session-timeline-context-resize.spec.ts",
+    "./utils/**/*.ts"
+  ]
 }
 }

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

@@ -1,7 +1,7 @@
 import type { Page, Route } from "@playwright/test"
 import type { Page, Route } from "@playwright/test"
 
 
 const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
 const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
-const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
+const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
 
 
 export interface MockServerConfig {
 export interface MockServerConfig {
   provider: unknown
   provider: unknown
@@ -17,6 +17,7 @@ export interface MockServerConfig {
   todos?: (sessionID: string) => unknown[]
   todos?: (sessionID: string) => unknown[]
   permissions?: unknown[] | (() => unknown[])
   permissions?: unknown[] | (() => unknown[])
   questions?: unknown[] | (() => unknown[])
   questions?: unknown[] | (() => unknown[])
+  sessionStatus?: unknown
 }
 }
 
 
 export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
 export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -53,6 +54,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
     if (path === "/question")
       return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
       return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
+    if (path === "/session/status") return json(route, config.sessionStatus ?? {})
     if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
     if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
     if (emptyObject.has(path)) return json(route, {})
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
     if (emptyList.has(path)) return json(route, [])

+ 284 - 0
packages/app/e2e/utils/sse-transport.ts

@@ -0,0 +1,284 @@
+import type { Page } from "@playwright/test"
+
+export type SseConnectionRecord = {
+  id: number
+  url: string
+  path: "/global/event" | "/event"
+  headers: Record<string, string>
+  openedAt: number
+  endedAt?: number
+  endedBy?: "close" | "disconnect" | "error" | "abort"
+  error?: string
+}
+
+export type SseDeliveryAcknowledgement = {
+  deliveryID: number
+  connectionID: number
+  bytes: number
+  chunkCount: number
+  deliveredAt: number
+  eventID?: string
+}
+
+export type SseEventOptions = {
+  id?: string
+  event?: string
+  retry?: number
+  marker?: string
+}
+
+export type SseTransport<T> = {
+  server: string
+  waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
+  send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
+  burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
+  split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
+  heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
+  writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
+  close(): Promise<void>
+  disconnect(message?: string): Promise<void>
+  error(message?: string): Promise<void>
+  connections(): Promise<SseConnectionRecord[]>
+  acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
+}
+
+type BrowserCommand<T> =
+  | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
+  | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
+  | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
+  | { type: "connections" }
+  | { type: "acknowledgements" }
+
+type BrowserTransport = Window & {
+  __testSseTransport?: {
+    command: (command: BrowserCommand<unknown>) => unknown
+  }
+}
+
+export async function installSseTransport<T>(
+  page: Page,
+  options: { server: string; retry?: number },
+): Promise<SseTransport<T>> {
+  const server = new URL(options.server).origin
+  await page.addInitScript(
+    ({ server, retry }) => {
+      type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
+      type ProbeWindow = Window & {
+        __visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
+      }
+      const originalFetch = window.fetch.bind(window)
+      const connections: Connection[] = []
+      const acknowledgements: SseDeliveryAcknowledgement[] = []
+      const encoder = new TextEncoder()
+      let nextConnectionID = 0
+      let nextDeliveryID = 0
+
+      const current = () => connections.findLast((connection) => connection.endedAt === undefined)
+      const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
+        const boundaries = [...new Set(cuts ?? [])]
+          .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
+          .sort((a, b) => a - b)
+        return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength))
+      }
+      const marker = (label?: string) => {
+        if (!label) return
+        const probe = (window as ProbeWindow).__visualStabilityProbe
+        if (!probe) return
+        probe.markers.push({ at: performance.now() - probe.startedAt, label })
+      }
+      const frame = (payload: unknown, eventOptions: SseEventOptions = {}) =>
+        [
+          eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`,
+          eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`,
+          eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
+          `data: ${JSON.stringify(payload)}\n\n`,
+        ].join("")
+      const acknowledge = (
+        connection: Connection,
+        bytes: number,
+        chunkCount: number,
+        eventID?: string,
+      ): SseDeliveryAcknowledgement => {
+        const acknowledgement = {
+          deliveryID: ++nextDeliveryID,
+          connectionID: connection.id,
+          bytes,
+          chunkCount,
+          deliveredAt: performance.now(),
+          ...(eventID === undefined ? {} : { eventID }),
+        }
+        acknowledgements.push(acknowledgement)
+        return acknowledgement
+      }
+      const end = (mode: "close" | "disconnect" | "error", message?: string) => {
+        const connection = current()
+        if (!connection) throw new Error("SSE transport has no active connection")
+        connection.endedAt = performance.now()
+        connection.endedBy = mode
+        if (message) connection.error = message
+        if (mode === "close") {
+          connection.controller.close()
+          return
+        }
+        const error = new DOMException(
+          message ?? "SSE connection disconnected",
+          mode === "error" ? "Error" : "NetworkError",
+        )
+        connection.controller.error(error)
+      }
+
+      const command = (input: BrowserCommand<unknown>) => {
+        if (input.type === "connections")
+          return connections.map(({ controller: _controller, ...connection }) => connection)
+        if (input.type === "acknowledgements") return acknowledgements
+        if (input.type === "end") return end(input.mode, input.message)
+        const connection = current()
+        if (!connection) throw new Error("SSE transport has no active connection")
+        if (input.type === "raw") {
+          marker(input.marker)
+          const output = chunks(new Uint8Array(input.bytes), input.cuts)
+          output.forEach((chunk) => connection.controller.enqueue(chunk))
+          return acknowledge(connection, input.bytes.length, output.length)
+        }
+        const encoded = input.deliveries.map((delivery) => ({
+          delivery,
+          bytes: encoder.encode(frame(delivery.payload, delivery.options)),
+        }))
+        encoded.forEach((item) => marker(item.delivery.options?.marker))
+        if (input.burst) {
+          const bytes = encoder.encode(
+            encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
+          )
+          connection.controller.enqueue(bytes)
+          return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
+        }
+        const output = chunks(encoded[0]!.bytes, input.cuts)
+        output.forEach((chunk) => connection.controller.enqueue(chunk))
+        return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
+      }
+
+      ;(window as BrowserTransport).__testSseTransport = { command }
+      const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
+        const request = new Request(input, init)
+        const url = new URL(request.url)
+        if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
+          return originalFetch(input, init)
+
+        const id = ++nextConnectionID
+        const record = {
+          id,
+          url: url.href,
+          path: url.pathname,
+          headers: Object.fromEntries(request.headers.entries()),
+          openedAt: performance.now(),
+        } as Connection
+        const stream = new ReadableStream<Uint8Array>({
+          start(controller) {
+            record.controller = controller
+            connections.push(record)
+            if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
+            request.signal.addEventListener(
+              "abort",
+              () => {
+                if (record.endedAt !== undefined) return
+                record.endedAt = performance.now()
+                record.endedBy = "abort"
+                controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
+              },
+              { once: true },
+            )
+          },
+          cancel() {
+            if (record.endedAt !== undefined) return
+            record.endedAt = performance.now()
+            record.endedBy = "disconnect"
+          },
+        })
+        return Promise.resolve(
+          new Response(stream, {
+            status: 200,
+            headers: {
+              "cache-control": "no-cache",
+              "content-type": "text/event-stream",
+            },
+          }),
+        )
+      }
+      Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
+    },
+    { server, retry: options.retry },
+  )
+
+  const command = <Result>(input: BrowserCommand<T>) =>
+    page.evaluate((input) => {
+      const transport = (window as BrowserTransport).__testSseTransport
+      if (!transport) throw new Error("SSE transport was not installed before page load")
+      return transport.command(input as BrowserCommand<unknown>)
+    }, input) as Promise<Result>
+
+  return {
+    server,
+    async waitForConnection(input = {}) {
+      await page.waitForFunction(
+        (after) => {
+          const transport = (window as BrowserTransport).__testSseTransport
+          const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
+          return connections?.some((connection) => connection.id > after)
+        },
+        input.after ?? 0,
+        { timeout: input.timeout },
+      )
+      return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
+        (connection) => connection.id > (input.after ?? 0),
+      )!
+    },
+    send(payload, eventOptions) {
+      return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
+    },
+    burst(payloads, eventOptions = []) {
+      return command({
+        type: "send",
+        deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
+        burst: true,
+      })
+    },
+    split(payload, cuts, eventOptions) {
+      return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
+    },
+    heartbeat(eventOptions) {
+      return command({
+        type: "send",
+        deliveries: [
+          {
+            payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
+            options: eventOptions,
+          },
+        ],
+        burst: false,
+      })
+    },
+    writeRaw(value, cuts, marker) {
+      return command({
+        type: "raw",
+        bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
+        cuts: cuts ? [...cuts] : undefined,
+        marker,
+      })
+    },
+    close() {
+      return command({ type: "end", mode: "close" })
+    },
+    disconnect(message) {
+      return command({ type: "end", mode: "disconnect", message })
+    },
+    error(message) {
+      return command({ type: "end", mode: "error", message })
+    },
+    connections() {
+      return command({ type: "connections" })
+    },
+    acknowledgements() {
+      return command({ type: "acknowledgements" })
+    },
+  }
+}

+ 54 - 0
packages/app/e2e/utils/visual-stability.ts

@@ -0,0 +1,54 @@
+import type { Page, TestInfo } from "@playwright/test"
+import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
+import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
+import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
+import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
+import type { VisualRegionDefinition } from "./visual-stability/regions"
+import { reportVisualStability } from "./visual-stability/reporter"
+
+export * from "./visual-stability/index"
+
+const capturedFrames = Symbol("capturedFrames")
+
+export async function startVisualStabilityProbe(page: Page, regions: Record<string, VisualRegionDefinition>) {
+  await startVisualProbe(page, regions)
+}
+
+export async function stopVisualStabilityProbe(page: Page) {
+  const result = await stopVisualProbe(page)
+  const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples }
+  Object.defineProperty(trace, capturedFrames, { value: result.frames })
+  return trace
+}
+
+export async function markVisualStability(page: Page, label: string) {
+  await markVisualProbe(page, label)
+}
+
+export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
+  return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
+}
+
+export function analyzeVisualStabilityByMarker(
+  trace: VisualStabilityTrace,
+  options: LegacyVisualStabilityOptions = {},
+) {
+  return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
+}
+
+export async function expectVisualStability(
+  testInfo: TestInfo,
+  name: string,
+  trace: VisualStabilityTrace,
+  options: LegacyVisualStabilityOptions = {},
+) {
+  await reportVisualStability(
+    testInfo,
+    name,
+    {
+      ...trace,
+      frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
+    },
+    legacyVisualPlan(options),
+  )
+}

+ 209 - 0
packages/app/e2e/utils/visual-stability/analyzer.ts

@@ -0,0 +1,209 @@
+import type { VisualInvariant, VisualPlan } from "./invariant"
+import type { VisualObservation, VisualStabilityTrace } from "./model"
+
+export function analyzeVisualObservations<RegionName extends string>(
+  observations: readonly VisualObservation<RegionName>[],
+  plan: VisualPlan<RegionName>,
+) {
+  const issues: string[] = []
+  const invariants = plan.invariants
+  const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
+  const required = regions(invariants, "required")
+  const continuousAny = invariants.filter(
+    (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
+      invariant.type === "continuous-any",
+  )
+  const unique = new Set(regions(invariants, "unique"))
+  const stable = new Set(regions(invariants, "stable"))
+  const fixed = invariants.filter(
+    (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
+  )
+  const opacity = invariants.filter(
+    (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
+  )
+  const continuity = invariants.filter(
+    (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
+      invariant.type === "continuity",
+  )
+  const motion = invariants.filter(
+    (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
+  )
+  const labelStability = invariants.filter(
+    (invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
+      invariant.type === "label-stability",
+  )
+
+  for (const name of new Set(required)) {
+    if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
+  }
+  for (const invariant of continuousAny) {
+    if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
+      issues.push(`${invariant.regions.join(" | ")} never rendered`)
+  }
+
+  for (const name of names) {
+    const samples = observations.flatMap((observation) => {
+      const region = observation.regions[name]
+      if (!region) return []
+      const clipped =
+        observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
+      return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
+    })
+    const visible = samples.filter((sample) => sample.visible)
+    if (visible.length === 0) continue
+    if (unique.has(name)) {
+      const duplicate = samples.find((sample) => sample.count > 1)
+      if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
+    }
+    if (stable.has(name)) {
+      const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
+      if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
+    }
+    for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
+      const origin = visible[0]
+      const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
+      if (movement > (invariant.tolerance ?? 1))
+        issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
+    }
+    for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
+      for (const sample of visible) {
+        if (sample.opacity < (invariant.floor ?? 0.65))
+          issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
+      }
+    }
+    if (continuity.some((invariant) => includes(invariant.regions, name))) {
+      const firstPresent = samples.findIndex((sample) => sample.present)
+      const lastPresent = samples.findLastIndex((sample) => sample.present)
+      if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
+        issues.push(`${name} disappeared between present frames`)
+      const firstVisible = samples.findIndex((sample) => sample.visible)
+      const lastVisible = samples.findLastIndex((sample) => sample.visible)
+      if (
+        firstVisible >= 0 &&
+        samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
+      )
+        issues.push(`${name} blanked between visible frames`)
+    }
+    for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
+      for (const metric of ["top", "bottom", "width", "height"] as const) {
+        const directions = visible
+          .slice(1)
+          .map((sample, index) => sample[metric] - visible[index]![metric])
+          .filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
+          .map(Math.sign)
+        const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
+        const allowed =
+          metric === "top" || metric === "bottom"
+            ? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
+            : (invariant.maxReversals ?? 1)
+        if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
+      }
+    }
+    if (labelStability.some((invariant) => includes(invariant.regions, name))) {
+      const labels = samples
+        .map((sample) => sample.label)
+        .filter((label) => label.length > 0)
+        .filter((label, index, all) => label !== all[index - 1])
+      if (labels.some((label, index) => labels.indexOf(label) !== index))
+        issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
+    }
+  }
+
+  if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
+    const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
+    if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
+      const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
+      if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
+    }
+  }
+  if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
+    const final = observations.findLast((sample) => sample.viewport)?.viewport
+    if (!final || final.distanceFromBottom > 4)
+      issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
+  }
+
+  for (const invariant of continuousAny) {
+    const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
+    const first = active.indexOf(true)
+    const last = active.lastIndexOf(true)
+    if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
+      issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
+  }
+
+  for (const invariant of invariants.filter(
+    (item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
+  )) {
+    for (const [before, after] of invariant.regions
+      .slice(1)
+      .map((after, index) => [invariant.regions[index]!, after])) {
+      let maximum: { overlap: number; at: number } | undefined
+      let inverted: { at: number } | undefined
+      for (const sample of observations) {
+        const first = sample.regions[before]
+        const second = sample.regions[after]
+        if (!first?.visible || !second?.visible) continue
+        if (
+          sample.viewport &&
+          (first.bottom <= sample.viewport.top ||
+            first.top >= sample.viewport.bottom ||
+            second.bottom <= sample.viewport.top ||
+            second.top >= sample.viewport.bottom)
+        )
+          continue
+        const overlap = first.bottom - second.top
+        if (first.top > second.top && !inverted) inverted = { at: sample.at }
+        if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
+          maximum = { overlap, at: sample.at }
+      }
+      if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
+      if (maximum)
+        issues.push(
+          `${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
+        )
+    }
+  }
+  return [...new Set(issues)]
+}
+
+export function analyzeVisualTraceByMarker<RegionName extends string>(
+  trace: VisualStabilityTrace<RegionName>,
+  plan: VisualPlan<RegionName>,
+) {
+  if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
+  const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
+    trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
+  )
+  const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
+  const windows = trace.markers.flatMap((marker, index) => {
+    const end = trace.markers[index + 1]?.at ?? Infinity
+    const before = trace.samples.findLast((sample) => sample.at < marker.at)
+    const samples = [
+      ...(before ? [before] : []),
+      ...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
+    ]
+    if (samples.length < 2) return []
+    return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
+      (issue) => `${marker.label}: ${issue}`,
+    )
+  })
+  const aggregateMotion =
+    plan.aggregateMotion === false
+      ? []
+      : analyzeVisualObservations(trace.samples, {
+          invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
+        }).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
+  return [...new Set([...required, ...aggregateMotion, ...windows])]
+}
+
+function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
+  invariants: readonly VisualInvariant<RegionName>[],
+  type: Type,
+) {
+  return invariants.flatMap((invariant) =>
+    invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
+  ) as RegionName[]
+}
+
+function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
+  return regions === "all" || regions.includes(name)
+}

+ 51 - 0
packages/app/e2e/utils/visual-stability/capture.ts

@@ -0,0 +1,51 @@
+import type { CDPSession, Page } from "@playwright/test"
+import type { CapturedFrame } from "./model"
+
+export type VisualCapture = {
+  session: CDPSession
+  frames: CapturedFrame[]
+  startedAtEpoch: number
+  running: boolean
+  capture: Promise<void>
+}
+
+export async function startVisualCapture(page: Page, startedAtEpoch: number) {
+  if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return
+  const session = await page.context().newCDPSession(page)
+  await session.send("Page.enable")
+  const recording: VisualCapture = {
+    session,
+    frames: [],
+    startedAtEpoch,
+    running: true,
+    capture: Promise.resolve(),
+  }
+  recording.capture = (async () => {
+    try {
+      while (recording.running && recording.frames.length < 900) {
+        const frame = await session.send("Page.captureScreenshot", {
+          format: "jpeg",
+          quality: 80,
+          captureBeyondViewport: false,
+          optimizeForSpeed: true,
+        })
+        recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data })
+        await new Promise((resolve) => setTimeout(resolve, 50))
+      }
+    } catch {
+      recording.running = false
+    }
+  })()
+  return recording
+}
+
+export async function stopVisualCapture(recording: VisualCapture | undefined) {
+  if (!recording) return []
+  recording.running = false
+  try {
+    await recording.capture
+  } finally {
+    await recording.session.detach().catch(() => undefined)
+  }
+  return recording.frames
+}

+ 8 - 0
packages/app/e2e/utils/visual-stability/index.ts

@@ -0,0 +1,8 @@
+export * from "./analyzer"
+export * from "./capture"
+export * from "./invariant"
+export * from "./model"
+export * from "./probe"
+export * from "./regions"
+export * from "./reporter"
+export * from "./scenario"

+ 112 - 0
packages/app/e2e/utils/visual-stability/invariant.ts

@@ -0,0 +1,112 @@
+import type { VisualRegionDefinition } from "./regions"
+
+type RegionSet<RegionName extends string> = readonly RegionName[] | "all"
+
+export type VisualInvariant<RegionName extends string = string> =
+  | { type: "required"; regions: readonly RegionName[] }
+  | { type: "continuous-any"; regions: readonly RegionName[] }
+  | { type: "unique"; regions: readonly RegionName[] }
+  | { type: "stable"; regions: readonly RegionName[] }
+  | { type: "fixed"; regions: readonly RegionName[]; tolerance?: number }
+  | { type: "opacity"; regions: RegionSet<RegionName>; floor?: number }
+  | {
+      type: "motion"
+      regions: RegionSet<RegionName>
+      tolerance?: number
+      maxReversals?: number
+      maxPositionReversals?: number
+    }
+  | { type: "continuity"; regions: RegionSet<RegionName> }
+  | { type: "label-stability"; regions: RegionSet<RegionName> }
+  | { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number }
+  | { type: "preserve-bottom-anchor" }
+  | { type: "acquire-bottom-anchor" }
+
+export type VisualPlan<RegionName extends string = string> = {
+  regionNames?: readonly RegionName[]
+  invariants: readonly VisualInvariant<RegionName>[]
+  markerRequired?: readonly RegionName[]
+  perMarker?: boolean
+  aggregateMotion?: boolean
+}
+
+export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
+  flow?: RegionName[]
+  motionTolerance?: number
+  opacityFloor?: number
+  overlapTolerance?: number
+  maxReversals?: number
+  maxPositionReversals?: number
+  stable?: RegionName[]
+  fixed?: RegionName[]
+  motion?: RegionName[]
+  unique?: RegionName[]
+  preserveBottomAnchor?: boolean
+  acquireBottomAnchor?: boolean
+  perMarker?: boolean
+  continuousAny?: RegionName[][]
+  required?: RegionName[]
+  aggregateMotion?: boolean
+  inferRequired?: boolean
+}
+
+export function visualPlan<const Regions extends Record<string, VisualRegionDefinition>>(
+  regions: Regions,
+  invariants: readonly VisualInvariant<Extract<keyof Regions, string>>[],
+  options: Omit<VisualPlan<Extract<keyof Regions, string>>, "regionNames" | "invariants"> = {},
+): VisualPlan<Extract<keyof Regions, string>> {
+  return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
+}
+
+export function legacyVisualPlan<RegionName extends string>(
+  options: LegacyVisualStabilityOptions<RegionName> = {},
+): VisualPlan<RegionName> {
+  const inferred =
+    options.inferRequired === false
+      ? []
+      : [
+          ...(options.stable ?? []),
+          ...(options.fixed ?? []),
+          ...(options.unique ?? []),
+          ...(options.motion ?? []),
+          ...(options.flow ?? []),
+        ]
+  return {
+    perMarker: options.perMarker,
+    aggregateMotion: options.aggregateMotion,
+    markerRequired: [
+      ...(options.required ?? []),
+      ...(options.stable ?? []),
+      ...(options.fixed ?? []),
+      ...(options.unique ?? []),
+      ...(options.motion ?? []),
+      ...(options.flow ?? []),
+    ],
+    invariants: [
+      { type: "required", regions: [...(options.required ?? []), ...inferred] },
+      ...(options.continuousAny ?? []).map(
+        (regions): VisualInvariant<RegionName> => ({ type: "continuous-any", regions }),
+      ),
+      ...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []),
+      ...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []),
+      ...(options.fixed
+        ? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }]
+        : []),
+      { type: "opacity", regions: "all", floor: options.opacityFloor },
+      { type: "continuity", regions: "all" },
+      {
+        type: "motion",
+        regions: options.motion ?? "all",
+        tolerance: options.motionTolerance,
+        maxReversals: options.maxReversals,
+        maxPositionReversals: options.maxPositionReversals,
+      },
+      { type: "label-stability", regions: "all" },
+      ...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []),
+      ...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []),
+      ...(options.flow
+        ? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }]
+        : []),
+    ],
+  }
+}

+ 47 - 0
packages/app/e2e/utils/visual-stability/model.ts

@@ -0,0 +1,47 @@
+export type VisualRegionSample = {
+  present: boolean
+  visible: boolean
+  inViewport: boolean
+  cssHidden?: boolean
+  top: number
+  bottom: number
+  layoutTop?: number
+  layoutBottom?: number
+  width: number
+  height: number
+  opacity: number
+  count: number
+  node: number
+  label: string
+  text: string
+}
+
+export type VisualViewportSample = {
+  top: number
+  bottom: number
+  scrollTop: number
+  scrollHeight: number
+  clientHeight: number
+  distanceFromBottom: number
+}
+
+export type VisualObservation<RegionName extends string = string> = {
+  at: number
+  regions: string extends RegionName
+    ? Record<string, VisualRegionSample>
+    : Partial<Record<RegionName, VisualRegionSample>>
+  viewport?: VisualViewportSample
+}
+
+export type VisualMarker = { at: number; label: string }
+
+export type VisualStabilityTrace<RegionName extends string = string> = {
+  markers: VisualMarker[]
+  samples: VisualObservation<RegionName>[]
+}
+
+export type CapturedFrame = { at: number; data: string }
+
+export type VisualProbeResult<RegionName extends string = string> = VisualStabilityTrace<RegionName> & {
+  frames: CapturedFrame[]
+}

+ 226 - 0
packages/app/e2e/utils/visual-stability/probe.ts

@@ -0,0 +1,226 @@
+import type { Page } from "@playwright/test"
+import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
+import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
+import type { VisualRegionDefinition } from "./regions"
+
+type ProbeWindow<RegionName extends string = string> = Window & {
+  __visualStabilityProbe?: {
+    startedAt: number
+    markers: VisualMarker[]
+    samples: VisualObservation<RegionName>[]
+    stop: () => void
+  }
+}
+
+const captures = new WeakMap<Page, VisualCapture>()
+
+export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
+  page: Page,
+  regions: Regions,
+) {
+  await stopCapture(page)
+  await page.evaluate(() => {
+    ;(window as ProbeWindow).__visualStabilityProbe?.stop()
+  })
+  const startedAtEpoch = await page.evaluate((regions) => {
+    const samples: VisualObservation[] = []
+    const markers: VisualMarker[] = []
+    const startedAt = performance.now()
+    const nodes = new WeakMap<Node, number>()
+    const lastBounds = new Map<string, { top: number; bottom: number }>()
+    let nextNode = 1
+    let running = true
+    const round = (value: number) => Math.round(value * 10) / 10
+    const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
+    const sample = () => {
+      if (!running) return
+      setTimeout(() => {
+        if (!running) return
+        const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
+          element.querySelector("[data-timeline-row]"),
+        )
+        const viewportRect = viewport?.getBoundingClientRect()
+        samples.push({
+          at: performance.now() - startedAt,
+          viewport: viewport
+            ? {
+                top: round(viewportRect!.top),
+                bottom: round(viewportRect!.bottom),
+                scrollTop: round(viewport.scrollTop),
+                scrollHeight: round(viewport.scrollHeight),
+                clientHeight: round(viewport.clientHeight),
+                distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
+              }
+            : undefined,
+          regions: Object.fromEntries(
+            Object.entries(regions).map(([name, config]) => {
+              const found = document.querySelector<HTMLElement>(config.selector)
+              const count = document.querySelectorAll(config.selector).length
+              const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
+              if (!element)
+                return [
+                  name,
+                  {
+                    present: false,
+                    visible: false,
+                    inViewport: false,
+                    top: 0,
+                    bottom: 0,
+                    width: 0,
+                    height: 0,
+                    opacity: 0,
+                    count,
+                    node: 0,
+                    label: "",
+                    text: "",
+                  },
+                ]
+              const rect = element.getBoundingClientRect()
+              const style = getComputedStyle(element)
+              if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
+              const known = rect.height > 0 ? rect : lastBounds.get(name)
+              const painted = (() => {
+                const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
+                let parent = element.parentElement
+                while (parent) {
+                  const parentStyle = getComputedStyle(parent)
+                  if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
+                    const parentRect = parent.getBoundingClientRect()
+                    result.top = Math.max(result.top, parentRect.top)
+                    result.bottom = Math.min(result.bottom, parentRect.bottom)
+                  }
+                  if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
+                    const parentRect = parent.getBoundingClientRect()
+                    result.left = Math.max(result.left, parentRect.left)
+                    result.right = Math.min(result.right, parentRect.right)
+                  }
+                  if (parent === viewport) break
+                  parent = parent.parentElement
+                }
+                if (viewportRect) {
+                  result.top = Math.max(result.top, viewportRect.top)
+                  result.bottom = Math.min(result.bottom, viewportRect.bottom)
+                  result.left = Math.max(result.left, viewportRect.left)
+                  result.right = Math.min(result.right, viewportRect.right)
+                }
+                return result
+              })()
+              const contentOpacity = config.opacitySelectors?.length
+                ? Math.max(
+                    0,
+                    ...config.opacitySelectors.flatMap((selector) =>
+                      [...element.querySelectorAll(selector)].map((node) => {
+                        let value = 1
+                        let current: Element | null = node
+                        while (current) {
+                          value *= opacity(current)
+                          if (current === element) break
+                          current = current.parentElement
+                        }
+                        return value
+                      }),
+                    ),
+                  )
+                : opacity(element)
+              let visibleOpacity = contentOpacity
+              let ancestor = element.parentElement
+              let ancestorHidden = false
+              while (ancestor) {
+                const ancestorStyle = getComputedStyle(ancestor)
+                visibleOpacity *= Number(ancestorStyle.opacity)
+                if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
+                if (ancestor === viewport) break
+                ancestor = ancestor.parentElement
+              }
+              const cssHidden =
+                ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
+              return [
+                name,
+                {
+                  present: true,
+                  visible:
+                    style.display !== "none" &&
+                    style.visibility !== "hidden" &&
+                    visibleOpacity > 0 &&
+                    painted.right > painted.left &&
+                    painted.bottom > painted.top,
+                  inViewport:
+                    !viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
+                  cssHidden,
+                  top: round(painted.top),
+                  bottom: round(painted.bottom),
+                  layoutTop: round(rect.top),
+                  layoutBottom: round(rect.bottom),
+                  width: round(painted.right - painted.left),
+                  height: round(painted.bottom - painted.top),
+                  opacity: round(visibleOpacity),
+                  count,
+                  node: (() => {
+                    const current = nodes.get(element)
+                    if (current) return current
+                    nodes.set(element, nextNode)
+                    return nextNode++
+                  })(),
+                  label: element.getAttribute("aria-label") ?? "",
+                  text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
+                },
+              ]
+            }),
+          ),
+        })
+        requestAnimationFrame(sample)
+      }, 0)
+    }
+    ;(window as ProbeWindow).__visualStabilityProbe = {
+      startedAt,
+      markers,
+      samples,
+      stop: () => {
+        running = false
+      },
+    }
+    requestAnimationFrame(sample)
+    return new Promise<number>((resolve) => {
+      const ready = () => {
+        if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
+        requestAnimationFrame(ready)
+      }
+      ready()
+    })
+  }, regions)
+  const capture = await startVisualCapture(page, startedAtEpoch)
+  if (capture) captures.set(page, capture)
+}
+
+export async function stopVisualProbe<RegionName extends string = string>(
+  page: Page,
+): Promise<VisualProbeResult<RegionName>> {
+  return page
+    .evaluate(() => {
+      const probe = (window as ProbeWindow).__visualStabilityProbe
+      if (!probe) throw new Error("Visual stability probe is not running")
+      probe.stop()
+      return { markers: probe.markers, samples: probe.samples }
+    })
+    .then(
+      async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
+      async (error: unknown) => {
+        await stopCapture(page)
+        throw error
+      },
+    )
+}
+
+export async function markVisualProbe(page: Page, label: string) {
+  await page.evaluate((label) => {
+    const probe = (window as ProbeWindow).__visualStabilityProbe
+    if (!probe) return
+    probe.markers.push({ at: performance.now() - probe.startedAt, label })
+  }, label)
+}
+
+async function stopCapture(page: Page) {
+  const capture = captures.get(page)
+  if (capture) captures.delete(page)
+  return stopVisualCapture(capture)
+}

+ 18 - 0
packages/app/e2e/utils/visual-stability/regions.ts

@@ -0,0 +1,18 @@
+export type VisualRegionDefinition = {
+  selector: string
+  closest?: string
+  opacitySelectors?: readonly string[]
+}
+
+export function defineVisualRegions<const Regions extends Record<string, VisualRegionDefinition>>(regions: Regions) {
+  return regions
+}
+
+export function mapVisualRegions<const Regions extends Record<string, VisualRegionDefinition>, Result>(
+  regions: Regions,
+  map: (region: Regions[keyof Regions], name: keyof Regions) => Result,
+) {
+  return Object.fromEntries(
+    Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]),
+  ) as { [Name in keyof Regions]: Result }
+}

+ 63 - 0
packages/app/e2e/utils/visual-stability/reporter.ts

@@ -0,0 +1,63 @@
+import { expect, type TestInfo } from "@playwright/test"
+import { writeFile } from "node:fs/promises"
+import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
+import type { VisualPlan } from "./invariant"
+import type { VisualProbeResult } from "./model"
+
+export async function reportVisualStability<RegionName extends string>(
+  testInfo: TestInfo,
+  name: string,
+  result: VisualProbeResult<RegionName>,
+  plan: VisualPlan<RegionName>,
+) {
+  const trace = { markers: result.markers, samples: result.samples }
+  const issues = plan.perMarker
+    ? analyzeVisualTraceByMarker(trace, plan)
+    : analyzeVisualObservations(result.samples, plan)
+  const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
+  const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
+  await writeFile(tracePath, JSON.stringify(trace, null, 2))
+  await writeFile(
+    issuesPath,
+    JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
+  )
+  await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
+  await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
+  if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
+  expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
+}
+
+async function attachViolationFrames<RegionName extends string>(
+  testInfo: TestInfo,
+  name: string,
+  result: VisualProbeResult<RegionName>,
+  issues: string[],
+) {
+  if (result.frames.length === 0) return
+  const targets = [
+    ...new Set(
+      issues.flatMap((issue) => {
+        const match = issue.match(/ at (\d+)ms/)
+        if (match) return [Number(match[1])]
+        const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
+        return marker ? [marker.at] : []
+      }),
+    ),
+  ].slice(0, 6)
+  for (const [violation, target] of targets.entries()) {
+    const nearest = result.frames.reduce(
+      (best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
+      0,
+    )
+    for (const [label, index] of [
+      ["before", Math.max(0, nearest - 1)],
+      ["violation", nearest],
+      ["after", Math.min(result.frames.length - 1, nearest + 1)],
+    ] as const) {
+      await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
+        body: Buffer.from(result.frames[index]!.data, "base64"),
+        contentType: "image/jpeg",
+      })
+    }
+  }
+}

+ 20 - 0
packages/app/e2e/utils/visual-stability/scenario.ts

@@ -0,0 +1,20 @@
+import type { Page, TestInfo } from "@playwright/test"
+import type { VisualPlan } from "./invariant"
+import { startVisualProbe, stopVisualProbe } from "./probe"
+import type { VisualRegionDefinition } from "./regions"
+import { reportVisualStability } from "./reporter"
+
+export async function runVisualStabilityScenario<const Regions extends Record<string, VisualRegionDefinition>>(input: {
+  page: Page
+  testInfo: TestInfo
+  name: string
+  regions: Regions
+  plan: VisualPlan<Extract<keyof Regions, string>>
+  run: () => Promise<void>
+}) {
+  await startVisualProbe(input.page, input.regions)
+  await input.run()
+  const result = await stopVisualProbe<Extract<keyof Regions, string>>(input.page)
+  await reportVisualStability(input.testInfo, input.name, result, input.plan)
+  return result
+}

+ 3 - 0
packages/app/package.json

@@ -13,6 +13,7 @@
   },
   },
   "scripts": {
   "scripts": {
     "typecheck": "tsgo -b",
     "typecheck": "tsgo -b",
+    "typecheck:e2e": "tsgo -p e2e/tsconfig.json",
     "start": "vite",
     "start": "vite",
     "dev": "vite",
     "dev": "vite",
     "build": "vite build",
     "build": "vite build",
@@ -25,6 +26,7 @@
     "test:e2e:local": "playwright test",
     "test:e2e:local": "playwright test",
     "test:e2e:ui": "playwright test --ui",
     "test:e2e:ui": "playwright test --ui",
     "test:e2e:report": "playwright show-report e2e/playwright-report",
     "test:e2e:report": "playwright show-report e2e/playwright-report",
+    "test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
     "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
     "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
   },
   },
   "license": "MIT",
   "license": "MIT",
@@ -51,6 +53,7 @@
     "@dnd-kit/solid": "0.5.0",
     "@dnd-kit/solid": "0.5.0",
     "@kobalte/core": "catalog:",
     "@kobalte/core": "catalog:",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/core": "workspace:*",
+    "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/session-ui": "workspace:*",
     "@opencode-ai/session-ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",

+ 6 - 3
packages/app/src/pages/session.tsx

@@ -26,6 +26,7 @@ import { FileProvider, selectionFromLines, useFile, type FileSelection, type Sel
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
+import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
@@ -940,9 +941,11 @@ export default function Page() {
       if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
       if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
     }
     }
 
 
-    // Only treat explicit scroll keys as potential "user scroll" gestures.
-    if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
-      markScrollGesture()
+    const key = scrollKey(event)
+    if (key) {
+      if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
+      if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
+      markScrollGesture(scroller)
       return
       return
     }
     }
 
 

+ 35 - 17
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -40,7 +40,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
 import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
-import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
@@ -441,6 +441,16 @@ export function MessageTimeline(props: {
     },
     },
   })
   })
   const resizeItem = virtualizer.resizeItem
   const resizeItem = virtualizer.resizeItem
+  let resizeAnchorScheduled = false
+  const anchorResizedBottom = () => {
+    if (resizeAnchorScheduled || props.hasScrollGesture()) return
+    resizeAnchorScheduled = true
+    queueMicrotask(() => {
+      resizeAnchorScheduled = false
+      if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
+      virtualizer.scrollToEnd()
+    })
+  }
   virtualizer.resizeItem = (index, size) => {
   virtualizer.resizeItem = (index, size) => {
     const item = virtualizer.measurementsCache[index]
     const item = virtualizer.measurementsCache[index]
     const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
     const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
@@ -462,9 +472,13 @@ export function MessageTimeline(props: {
       })
       })
     }
     }
     resizeItem(index, size)
     resizeItem(index, size)
+    if (root && props.shouldAnchorBottom()) anchorResizedBottom()
+  }
+  virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
+    if (props.shouldAnchorBottom()) return false
+    const first = virtualizer.range?.startIndex
+    return first !== undefined && item.index < first
   }
   }
-  virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
-    item.end <= instance.getLogicalScrollOffset()
   const virtualItemByKey = createMemo(
   const virtualItemByKey = createMemo(
     () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
     () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
   )
   )
@@ -491,24 +505,13 @@ export function MessageTimeline(props: {
     })
     })
   })
   })
 
 
-  let bottomAnchorSessionKey = ""
-  let bottomAnchorFrame: number | undefined
-
   const maybeAnchorBottom = () => {
   const maybeAnchorBottom = () => {
-    const key = sessionKey()
-    if (bottomAnchorSessionKey === key) return
     if (timelineRows().length === 0) return
     if (timelineRows().length === 0) return
-    bottomAnchorSessionKey = key
-    if (!props.shouldAnchorBottom()) return
-    if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
+    if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
     if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
     if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
     clearPrependAnchor()
     clearPrependAnchor()
     if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
     if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
-    bottomAnchorFrame = requestAnimationFrame(() => {
-      bottomAnchorFrame = undefined
-      if (sessionKey() !== key) return
-      virtualizer.scrollToEnd()
-    })
+    virtualizer.scrollToEnd()
   }
   }
 
 
   let measuredSessionKey = sessionKey()
   let measuredSessionKey = sessionKey()
@@ -527,7 +530,6 @@ export function MessageTimeline(props: {
     timelineCache.delete(ownerSessionKey)
     timelineCache.delete(ownerSessionKey)
     timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
     timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
     while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
     while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
-    if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
     if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
     if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
     if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
     if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
     props.setRevealMessage?.(() => {})
     props.setRevealMessage?.(() => {})
@@ -600,6 +602,15 @@ export function MessageTimeline(props: {
     props.onMarkScrollGesture(event.currentTarget)
     props.onMarkScrollGesture(event.currentTarget)
   }
   }
 
 
+  const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
+    const key = scrollKey(event)
+    if (!key) return
+    if (!isScrollKeyTarget(event.target, key)) return
+    if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
+    if (!prependLoading) clearPrependAnchor()
+    props.onMarkScrollGesture(event.currentTarget)
+  }
+
   const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
   const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
     if (prependLoading) updatePrependAnchor()
     if (prependLoading) updatePrependAnchor()
     props.onScheduleScrollState(event.currentTarget)
     props.onScheduleScrollState(event.currentTarget)
@@ -976,10 +987,16 @@ export function MessageTimeline(props: {
           .map((ref) => getMsgPart(ref.messageID, ref.partID))
           .map((ref) => getMsgPart(ref.messageID, ref.partID))
           .filter((part): part is ToolPart => part?.type === "tool")
           .filter((part): part is ToolPart => part?.type === "tool")
       })
       })
+      const contextOpenKey = () => `context:${row().group.key}`
+      const open = createMemo(() => {
+        return toolOpen[contextOpenKey()] === true
+      })
 
 
       return (
       return (
         <ContextToolGroup
         <ContextToolGroup
           parts={parts()}
           parts={parts()}
+          open={open()}
+          onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
           busy={
           busy={
             workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
             workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
           }
           }
@@ -1339,6 +1356,7 @@ export function MessageTimeline(props: {
         onTouchEnd={handleListTouchEnd}
         onTouchEnd={handleListTouchEnd}
         onTouchCancel={handleListTouchEnd}
         onTouchCancel={handleListTouchEnd}
         onPointerDown={handleListPointerDown}
         onPointerDown={handleListPointerDown}
+        onKeyDown={handleListKeyDown}
         onScroll={handleListScroll}
         onScroll={handleListScroll}
         onClick={props.onAutoScrollInteraction}
         onClick={props.onAutoScrollInteraction}
         class="relative min-w-0 w-full h-full"
         class="relative min-w-0 w-full h-full"

+ 96 - 0
packages/app/src/pages/session/timeline/projection.test.ts

@@ -0,0 +1,96 @@
+import { describe, expect, test } from "bun:test"
+import type { PartGroup } from "@opencode-ai/session-ui/message-part"
+import { reuseTimelineRows } from "./row-reconciliation"
+import { TimelineRow } from "./timeline-row"
+
+const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
+  new TimelineRow.AssistantPart({
+    userMessageID,
+    group: {
+      key,
+      type: "context",
+      refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
+    } satisfies PartGroup,
+    previousAssistantPart: false,
+  })
+
+const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
+const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
+
+describe("reuseTimelineRows", () => {
+  test.each([
+    {
+      name: "reuses an unchanged context group",
+      previous: [context("context:a", ["a", "b"])],
+      rows: [context("context:a", ["a", "b"])],
+      expected: ["assistant-part:user-1:context:a"],
+      reused: [[0, 0]],
+    },
+    {
+      name: "preserves the group key when a member is appended",
+      previous: [context("context:a", ["a"])],
+      rows: [context("context:a", ["a", "b"])],
+      expected: ["assistant-part:user-1:context:a"],
+      reused: [],
+    },
+    {
+      name: "preserves the group key when the first member is removed",
+      previous: [context("context:a", ["a", "b"])],
+      rows: [context("context:b", ["b"])],
+      expected: ["assistant-part:user-1:context:a"],
+      reused: [],
+    },
+    {
+      name: "lets only the natural owner retain an old key after a split",
+      previous: [context("context:a", ["a", "b"])],
+      rows: [context("context:a", ["a"]), context("context:b", ["b"])],
+      expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
+      reused: [],
+    },
+    {
+      name: "chooses the earliest prior key when groups merge",
+      previous: [context("context:a", ["a"]), context("context:b", ["b"])],
+      rows: [context("context:b", ["b", "a"])],
+      expected: ["assistant-part:user-1:context:a"],
+      reused: [],
+    },
+    {
+      name: "reserves an old key for its natural owner when two new groups compete",
+      previous: [context("context:a", ["a", "b"])],
+      rows: [context("context:b", ["b"]), context("context:a", ["a"])],
+      expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
+      reused: [],
+    },
+    {
+      name: "does not reuse context identity across user messages",
+      previous: [context("context:a", ["a", "b"], "user-1")],
+      rows: [context("context:b", ["b"], "user-2")],
+      expected: ["assistant-part:user-2:context:b"],
+      reused: [],
+    },
+    {
+      name: "reuses an unaffected ordinary row",
+      previous: [user()],
+      rows: [user()],
+      expected: ["user-message:user-1"],
+      reused: [[0, 0]],
+    },
+    {
+      name: "does not create accidental key collisions",
+      previous: [context("context:a", ["a", "b", "c"])],
+      rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
+      expected: [
+        "assistant-part:user-1:context:b",
+        "assistant-part:user-1:context:a",
+        "assistant-part:user-1:context:c",
+      ],
+      reused: [],
+    },
+  ])("$name", ({ previous, rows, expected, reused }) => {
+    const result = reuseTimelineRows([...previous], [...rows])
+
+    expect(keys(result)).toEqual([...expected])
+    expect(new Set(keys(result)).size).toBe(result.length)
+    reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
+  })
+})

+ 3 - 12
packages/app/src/pages/session/timeline/projection.ts

@@ -1,8 +1,11 @@
 import { Binary } from "@opencode-ai/core/util/binary"
 import { Binary } from "@opencode-ai/core/util/binary"
 import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
 import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
 import { createMemo, mapArray, type Accessor } from "solid-js"
 import { createMemo, mapArray, type Accessor } from "solid-js"
+import { reuseTimelineRows } from "./row-reconciliation"
 import { Timeline, TimelineRow } from "./rows"
 import { Timeline, TimelineRow } from "./rows"
 
 
+export { reuseTimelineRows } from "./row-reconciliation"
+
 const emptyAssistantMessages: AssistantMessage[] = []
 const emptyAssistantMessages: AssistantMessage[] = []
 
 
 export function createTimelineProjection(input: {
 export function createTimelineProjection(input: {
@@ -102,15 +105,3 @@ export function createTimelineProjection(input: {
     rows,
     rows,
   }
   }
 }
 }
-
-export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
-  if (!previous?.length) return rows
-  const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
-  const next = rows.map((row) => {
-    const existing = byKey.get(TimelineRow.key(row))
-    if (!existing) return row
-    return TimelineRow.equals(existing, row) ? existing : row
-  })
-  if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
-  return next
-}

+ 56 - 0
packages/app/src/pages/session/timeline/row-reconciliation.ts

@@ -0,0 +1,56 @@
+import { TimelineRow } from "./timeline-row"
+
+type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
+type PriorContext = { index: number; row: ContextRow }
+
+export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
+  if (!previous?.length) return rows
+  const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
+  const contextByPart = new Map<string, PriorContext>()
+  previous.forEach((row, index) => {
+    if (row._tag !== "AssistantPart" || row.group.type !== "context") return
+    row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
+  })
+  const reserved = new Map<string, number>()
+  rows.forEach((row, index) => {
+    if (row._tag !== "AssistantPart" || row.group.type !== "context") return
+    const key = TimelineRow.key(row)
+    if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
+  })
+  const claimed = new Set<string>()
+  const next = rows.map((input, index) => {
+    const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
+    const existing = byKey.get(TimelineRow.key(row))
+    if (!existing) return row
+    return TimelineRow.equals(existing, row) ? existing : row
+  })
+  if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
+  return next
+}
+
+function stabilizeContextKey(
+  contextByPart: Map<string, PriorContext>,
+  reserved: Map<string, number>,
+  row: TimelineRow.TimelineRow,
+  rowIndex: number,
+  claimed: Set<string>,
+) {
+  if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
+  const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
+    const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
+    if (!candidate) return result
+    const key = TimelineRow.key(candidate.row)
+    if (claimed.has(key)) return result
+    const owner = reserved.get(key)
+    if (owner !== undefined && owner !== rowIndex) return result
+    return !result || candidate.index < result.index ? candidate : result
+  }, undefined)
+  if (!existing) return row
+  const key = TimelineRow.key(existing.row)
+  claimed.add(key)
+  if (row.group.key === existing.row.group.key) return row
+  return new TimelineRow.AssistantPart({
+    ...row,
+    group: { ...row.group, key: existing.row.group.key },
+  })
+}

+ 3 - 78
packages/app/src/pages/session/timeline/rows.ts

@@ -1,9 +1,9 @@
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
 import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
 import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
-import { groupParts, PartGroup, renderable } from "@opencode-ai/session-ui/message-part"
-import { Data, Equal } from "effect"
+import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
+import { TimelineRow, type SummaryDiff } from "./timeline-row"
 
 
-export type SummaryDiff = SnapshotFileDiff & { file: string }
+export { TimelineRow, type SummaryDiff } from "./timeline-row"
 
 
 export type TimelineRowMap = {
 export type TimelineRowMap = {
   TurnGap: { userMessageID: string }
   TurnGap: { userMessageID: string }
@@ -29,81 +29,6 @@ export type TimelineRowMap = {
   Error: { userMessageID: string; text: string }
   Error: { userMessageID: string; text: string }
 }
 }
 
 
-export namespace TimelineRow {
-  export class TurnGap extends Data.TaggedClass("TurnGap")<{
-    userMessageID: string
-  }> {}
-  export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
-    userMessageID: string
-  }> {}
-  export class UserMessage extends Data.TaggedClass("UserMessage")<{
-    userMessageID: string
-    anchor: boolean
-  }> {}
-  export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
-    userMessageID: string
-    label: "compaction" | "interrupted"
-  }> {}
-  export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
-    userMessageID: string
-    group: PartGroup
-    previousAssistantPart: boolean
-  }> {}
-  export class Thinking extends Data.TaggedClass("Thinking")<{
-    userMessageID: string
-    reasoningHeading?: string
-  }> {}
-  export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
-    userMessageID: string
-    diffs: SummaryDiff[]
-  }> {}
-  export class Error extends Data.TaggedClass("Error")<{
-    userMessageID: string
-    text: string
-  }> {}
-  export class Retry extends Data.TaggedClass("Retry")<{
-    userMessageID: string
-  }> {}
-
-  export type TimelineRow =
-    | TurnGap
-    | CommentStrip
-    | UserMessage
-    | TurnDivider
-    | AssistantPart
-    | Thinking
-    | DiffSummary
-    | Error
-    | Retry
-
-  export const key = (row: TimelineRow) => {
-    switch (row._tag) {
-      case "TurnGap":
-        return `turn-gap:${row.userMessageID}`
-      case "CommentStrip":
-        return `comment-strip:${row.userMessageID}`
-      case "UserMessage":
-        return `user-message:${row.userMessageID}`
-      case "TurnDivider":
-        return `turn-divider:${row.userMessageID}:${row.label}`
-      case "AssistantPart":
-        return `assistant-part:${row.userMessageID}:${row.group.key}`
-      case "Thinking":
-        return `thinking:${row.userMessageID}`
-      case "DiffSummary":
-        return `diff-summary:${row.userMessageID}`
-      case "Error":
-        return `error:${row.userMessageID}`
-      case "Retry":
-        return `retry:${row.userMessageID}`
-    }
-  }
-
-  export function equals(a: TimelineRow, b: TimelineRow) {
-    return Equal.equals(a, b)
-  }
-}
-
 export namespace Timeline {
 export namespace Timeline {
   export function constructMessageRows(
   export function constructMessageRows(
     userMessage: UserMessage,
     userMessage: UserMessage,

+ 80 - 0
packages/app/src/pages/session/timeline/timeline-row.ts

@@ -0,0 +1,80 @@
+import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import type { PartGroup } from "@opencode-ai/session-ui/message-part"
+import { Data, Equal } from "effect"
+
+export type SummaryDiff = SnapshotFileDiff & { file: string }
+
+export namespace TimelineRow {
+  export class TurnGap extends Data.TaggedClass("TurnGap")<{
+    userMessageID: string
+  }> {}
+  export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
+    userMessageID: string
+  }> {}
+  export class UserMessage extends Data.TaggedClass("UserMessage")<{
+    userMessageID: string
+    anchor: boolean
+  }> {}
+  export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
+    userMessageID: string
+    label: "compaction" | "interrupted"
+  }> {}
+  export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
+    userMessageID: string
+    group: PartGroup
+    previousAssistantPart: boolean
+  }> {}
+  export class Thinking extends Data.TaggedClass("Thinking")<{
+    userMessageID: string
+    reasoningHeading?: string
+  }> {}
+  export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
+    userMessageID: string
+    diffs: SummaryDiff[]
+  }> {}
+  export class Error extends Data.TaggedClass("Error")<{
+    userMessageID: string
+    text: string
+  }> {}
+  export class Retry extends Data.TaggedClass("Retry")<{
+    userMessageID: string
+  }> {}
+
+  export type TimelineRow =
+    | TurnGap
+    | CommentStrip
+    | UserMessage
+    | TurnDivider
+    | AssistantPart
+    | Thinking
+    | DiffSummary
+    | Error
+    | Retry
+
+  export const key = (row: TimelineRow) => {
+    switch (row._tag) {
+      case "TurnGap":
+        return `turn-gap:${row.userMessageID}`
+      case "CommentStrip":
+        return `comment-strip:${row.userMessageID}`
+      case "UserMessage":
+        return `user-message:${row.userMessageID}`
+      case "TurnDivider":
+        return `turn-divider:${row.userMessageID}:${row.label}`
+      case "AssistantPart":
+        return `assistant-part:${row.userMessageID}:${row.group.key}`
+      case "Thinking":
+        return `thinking:${row.userMessageID}`
+      case "DiffSummary":
+        return `diff-summary:${row.userMessageID}`
+      case "Error":
+        return `error:${row.userMessageID}`
+      case "Retry":
+        return `retry:${row.userMessageID}`
+    }
+  }
+
+  export function equals(a: TimelineRow, b: TimelineRow) {
+    return Equal.equals(a, b)
+  }
+}

+ 14 - 11
packages/session-ui/src/components/basic-tool.tsx

@@ -37,7 +37,9 @@ export interface BasicToolProps {
   animated?: boolean
   animated?: boolean
   onSubtitleClick?: () => void
   onSubtitleClick?: () => void
   onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
   onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
+  onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
   triggerHref?: string
   triggerHref?: string
+  triggerAsLink?: boolean
   clickable?: boolean
   clickable?: boolean
 }
 }
 
 
@@ -254,7 +256,7 @@ export function BasicTool(props: BasicToolProps) {
   return (
   return (
     <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
     <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
       <Show
       <Show
-        when={props.triggerHref}
+        when={props.triggerAsLink || props.triggerHref}
         fallback={
         fallback={
           <Collapsible.Trigger
           <Collapsible.Trigger
             data-hide-details={props.hideDetails ? "true" : undefined}
             data-hide-details={props.hideDetails ? "true" : undefined}
@@ -264,16 +266,17 @@ export function BasicTool(props: BasicToolProps) {
           </Collapsible.Trigger>
           </Collapsible.Trigger>
         }
         }
       >
       >
-        {(href) => (
-          <Collapsible.Trigger
-            as="a"
-            href={href()}
-            data-hide-details={props.hideDetails ? "true" : undefined}
-            onClick={props.onTriggerClick}
-          >
-            {trigger()}
-          </Collapsible.Trigger>
-        )}
+        <Collapsible.Trigger
+          as="a"
+          href={props.triggerHref}
+          role={!props.triggerHref && props.clickable ? "button" : undefined}
+          tabIndex={!props.triggerHref && props.clickable ? 0 : undefined}
+          data-hide-details={props.hideDetails ? "true" : undefined}
+          onClick={props.onTriggerClick}
+          onKeyDown={props.onTriggerKeyDown}
+        >
+          {trigger()}
+        </Collapsible.Trigger>
       </Show>
       </Show>
       <Show when={props.animated && hasChildren() && !props.hideDetails}>
       <Show when={props.animated && hasChildren() && !props.hideDetails}>
         <div
         <div

+ 47 - 7
packages/session-ui/src/components/message-part.tsx

@@ -1000,16 +1000,24 @@ export function AssistantMessageDisplay(props: {
   )
   )
 }
 }
 
 
-export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
+export function ContextToolGroup(props: {
+  parts: ToolPart[]
+  busy?: boolean
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
+  onSizeChange?: () => void
+}) {
   const i18n = useI18n()
   const i18n = useI18n()
-  const [open, setOpen] = createSignal(false)
+  const [localOpen, setLocalOpen] = createSignal(false)
+  const open = () => props.open ?? localOpen()
   const pending = createMemo(
   const pending = createMemo(
     () =>
     () =>
       !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
       !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
   )
   )
   const summary = createMemo(() => contextToolSummary(props.parts))
   const summary = createMemo(() => contextToolSummary(props.parts))
   const handleOpenChange = (value: boolean) => {
   const handleOpenChange = (value: boolean) => {
-    setOpen(value)
+    if (props.open === undefined) setLocalOpen(value)
+    props.onOpenChange?.(value)
     props.onSizeChange?.()
     props.onSizeChange?.()
   }
   }
 
 
@@ -1710,7 +1718,13 @@ ToolRegistry.register({
         trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
         trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
       >
       >
         <Show when={props.output}>
         <Show when={props.output}>
-          <div data-component="tool-output" data-scrollable>
+          <div
+            data-component="tool-output"
+            data-scrollable
+            tabIndex={0}
+            role="region"
+            aria-label={i18n.t("ui.scrollView.ariaLabel")}
+          >
             <Markdown text={props.output!} />
             <Markdown text={props.output!} />
           </div>
           </div>
         </Show>
         </Show>
@@ -1734,7 +1748,13 @@ ToolRegistry.register({
         }}
         }}
       >
       >
         <Show when={props.output}>
         <Show when={props.output}>
-          <div data-component="tool-output" data-scrollable>
+          <div
+            data-component="tool-output"
+            data-scrollable
+            tabIndex={0}
+            role="region"
+            aria-label={i18n.t("ui.scrollView.ariaLabel")}
+          >
             <Markdown text={props.output!} />
             <Markdown text={props.output!} />
           </div>
           </div>
         </Show>
         </Show>
@@ -1761,7 +1781,13 @@ ToolRegistry.register({
         }}
         }}
       >
       >
         <Show when={props.output}>
         <Show when={props.output}>
-          <div data-component="tool-output" data-scrollable>
+          <div
+            data-component="tool-output"
+            data-scrollable
+            tabIndex={0}
+            role="region"
+            aria-label={i18n.t("ui.scrollView.ariaLabel")}
+          >
             <Markdown text={props.output!} />
             <Markdown text={props.output!} />
           </div>
           </div>
         </Show>
         </Show>
@@ -1887,6 +1913,12 @@ ToolRegistry.register({
       event.preventDefault()
       event.preventDefault()
       open()
       open()
     }
     }
+    const navigateKey = (event: KeyboardEvent) => {
+      if (!clickable() || href()) return
+      if (event.key !== "Enter" && event.key !== " ") return
+      event.preventDefault()
+      open()
+    }
 
 
     const trigger = () => (
     const trigger = () => (
       <div data-component="task-tool-card">
       <div data-component="task-tool-card">
@@ -1919,9 +1951,11 @@ ToolRegistry.register({
         status={props.status}
         status={props.status}
         trigger={trigger()}
         trigger={trigger()}
         hideDetails
         hideDetails
+        triggerAsLink
         triggerHref={href()}
         triggerHref={href()}
         clickable={clickable()}
         clickable={clickable()}
         onTriggerClick={navigate}
         onTriggerClick={navigate}
+        onTriggerKeyDown={navigateKey}
       />
       />
     )
     )
   },
   },
@@ -1979,7 +2013,13 @@ ToolRegistry.register({
               />
               />
             </TooltipV2>
             </TooltipV2>
           </div>
           </div>
-          <div data-slot="bash-scroll" data-scrollable>
+          <div
+            data-slot="bash-scroll"
+            data-scrollable
+            tabIndex={0}
+            role="region"
+            aria-label={i18n.t("ui.scrollView.ariaLabel")}
+          >
             <pre data-slot="bash-pre">
             <pre data-slot="bash-pre">
               <code>{text()}</code>
               <code>{text()}</code>
             </pre>
             </pre>

+ 22 - 1
packages/ui/src/components/scroll-view.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
-import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
+import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
 
 
 describe("scrollKey", () => {
 describe("scrollKey", () => {
   test("maps plain navigation keys", () => {
   test("maps plain navigation keys", () => {
@@ -16,6 +16,27 @@ describe("scrollKey", () => {
     expect(scrollKey({ key: "PageUp", altKey: false, ctrlKey: true, metaKey: false, shiftKey: false })).toBeUndefined()
     expect(scrollKey({ key: "PageUp", altKey: false, ctrlKey: true, metaKey: false, shiftKey: false })).toBeUndefined()
     expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined()
     expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined()
   })
   })
+
+  test("maps space and shift-space directions", () => {
+    expect(scrollKey({ key: " ", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe("page-down")
+    expect(scrollKey({ key: " ", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBe("page-up")
+  })
+})
+
+describe("canScrollKey", () => {
+  const element = (scrollTop: number, clientHeight = 100, scrollHeight = 300) =>
+    ({ scrollTop, clientHeight, scrollHeight }) as HTMLElement
+
+  test("owns upward keys only above the top boundary", () => {
+    expect(canScrollKey(element(50), "page-up")).toBe(true)
+    expect(canScrollKey(element(0), "page-up")).toBe(false)
+  })
+
+  test("owns downward keys only before the bottom boundary", () => {
+    expect(canScrollKey(element(50), "page-down")).toBe(true)
+    expect(canScrollKey(element(200), "page-down")).toBe(false)
+    expect(canScrollKey(element(0, 100, 100), "page-down")).toBe(false)
+  })
 })
 })
 
 
 describe("scrollTopFromThumbPointer", () => {
 describe("scrollTopFromThumbPointer", () => {

+ 32 - 2
packages/ui/src/components/scroll-view.tsx

@@ -9,7 +9,8 @@ export interface ScrollViewProps extends ComponentProps<"div"> {
 }
 }
 
 
 export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
 export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
-  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
+  if (event.altKey || event.ctrlKey || event.metaKey) return
+  if (event.shiftKey && event.key !== " ") return
 
 
   switch (event.key) {
   switch (event.key) {
     case "PageDown":
     case "PageDown":
@@ -24,9 +25,36 @@ export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey
       return "up"
       return "up"
     case "ArrowDown":
     case "ArrowDown":
       return "down"
       return "down"
+    case " ":
+      return event.shiftKey ? "page-up" : "page-down"
   }
   }
 }
 }
 
 
+export function canScrollKey(element: HTMLElement, key: NonNullable<ReturnType<typeof scrollKey>>) {
+  const up = key === "up" || key === "page-up" || key === "home"
+  return up ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight
+}
+
+export function scrollKeyOwner(
+  root: HTMLElement,
+  target: EventTarget | null,
+  key: NonNullable<ReturnType<typeof scrollKey>>,
+) {
+  const element = target instanceof Element ? target : undefined
+  const owner = element?.closest<HTMLElement>("[data-scrollable]")
+  if (!owner || owner === root) return root
+  if (!root.contains(owner)) return owner
+  return canScrollKey(owner, key) ? owner : root
+}
+
+export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<ReturnType<typeof scrollKey>>) {
+  const element = target instanceof HTMLElement ? target : undefined
+  if (!element) return true
+  if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName) || element.isContentEditable) return false
+  if ((key === "page-up" || key === "page-down") && element.closest('button, a[href], [role="button"]')) return false
+  return true
+}
+
 export function scrollTopFromThumbPointer(input: {
 export function scrollTopFromThumbPointer(input: {
   pointer: number
   pointer: number
   viewportTop: number
   viewportTop: number
@@ -160,9 +188,10 @@ export function ScrollView(props: ScrollViewProps) {
     if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
     if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
       return
       return
     }
     }
-
     const next = scrollKey(e)
     const next = scrollKey(e)
     if (!next) return
     if (!next) return
+    if (!isScrollKeyTarget(e.target, next)) return
+    if (scrollKeyOwner(viewportRef, e.target, next) !== viewportRef) return
 
 
     const scrollAmount = viewportRef.clientHeight * 0.8
     const scrollAmount = viewportRef.clientHeight * 0.8
     const lineAmount = 40
     const lineAmount = 40
@@ -208,6 +237,7 @@ export function ScrollView(props: ScrollViewProps) {
       <div
       <div
         ref={viewportRef}
         ref={viewportRef}
         class="scroll-view__viewport"
         class="scroll-view__viewport"
+        data-scrollable
         onScroll={(e) => {
         onScroll={(e) => {
           updateThumb()
           updateThumb()
           if (typeof events.onScroll === "function") events.onScroll(e as any)
           if (typeof events.onScroll === "function") events.onScroll(e as any)