Преглед изворни кода

fix(app): optimize large review panes (#35375)

Luke Parker пре 1 месец
родитељ
комит
3a149ba71c

+ 312 - 0
packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts

@@ -0,0 +1,312 @@
+import type { Page } from "@playwright/test"
+import { benchmark, expect } from "../benchmark"
+import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
+
+const changedLinesPerFile = 100
+const linesPerSide = changedLinesPerFile / 2
+const fileCounts = [1, 10, 100, 1_000, 10_000]
+const filesPerDirectory = 100
+const readyFrames = 3
+const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
+
+type ReviewPaneScalingSample = {
+  observedAtMs: number
+  logicalRows: number
+  treeRows: number
+  fileRows: number
+  diffLines: number
+  header: string
+  ready: boolean
+}
+
+type ReviewPaneScalingProbe = {
+  startedAt?: number
+  firstTreeRowMs?: number
+  logicalTreeReadyMs?: number
+  firstDiffRenderMs?: number
+  stableReadyMs?: number
+  samples: ReviewPaneScalingSample[]
+  frameTimesMs: number[]
+  longTasks: { startTime: number; duration: number }[]
+  stop: () => void
+}
+
+benchmark.describe("performance: review pane scaling", () => {
+  for (const fileCount of fileCounts) {
+    const changedLines = fileCount * changedLinesPerFile
+
+    benchmark(
+      `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
+      async ({ page, report }) => {
+        benchmark.setTimeout(1_200_000)
+        await page.emulateMedia({ reducedMotion: "reduce" })
+
+        const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
+        if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
+          throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
+        const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
+        await setupTimelineBenchmark(page, {
+          historyTurns: 0,
+          eventBatch: 1,
+          newLayoutDesigns: true,
+        })
+        await page.route("**/vcs/diff**", (route) =>
+          route.fulfill({
+            status: 200,
+            contentType: "application/json",
+            headers: { "access-control-allow-origin": "*" },
+            body: responseBody,
+          }),
+        )
+
+        const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
+        const metrics = await measureReviewPaneLoad(page, {
+          expectedFile: reviewFile(0),
+          expectedRows,
+        })
+        const search = await measureBroadReviewSearch(page, fileCount)
+
+        expect(metrics.logicalRows).toBe(expectedRows)
+        expect(metrics.fileRows).toBeGreaterThan(0)
+        expect(metrics.treeRows).toBeGreaterThan(0)
+        expect(metrics.diffLines).toBeGreaterThan(0)
+        expect(search.logicalRows).toBe(fileCount)
+        expect(search.renderedRows).toBeGreaterThan(0)
+        report(
+          { ...metrics, search },
+          {
+            fileCount,
+            changedLinesPerFile,
+            changedLines,
+            additions: changedLines / 2,
+            deletions: changedLines / 2,
+            patchLines: changedLines,
+            patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
+            payloadBytes: new TextEncoder().encode(responseBody).byteLength,
+            expectedRows,
+          },
+        )
+      },
+    )
+  }
+})
+
+async function measureBroadReviewSearch(page: Page, expectedRows: number) {
+  const filter = page.getByRole("searchbox", { name: "Filter files" })
+  await filter.evaluate((element) => {
+    element.addEventListener(
+      "input",
+      () => {
+        ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
+      },
+      { once: true, capture: true },
+    )
+  })
+  await filter.fill("file-")
+
+  return page.evaluate((expectedRows) => {
+    const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
+    return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
+      let previous = -1
+      let streak = 0
+      const sample = () => {
+        const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
+        const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
+        const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
+        const ready =
+          logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
+        streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
+        previous = rows.length
+        if (streak >= 3) {
+          resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
+          return
+        }
+        requestAnimationFrame(sample)
+      }
+      requestAnimationFrame(sample)
+    })
+  }, expectedRows)
+}
+
+function createScalingDiffs(fileCount: number, patchByteLimit: number) {
+  const changes = Array.from({ length: linesPerSide }, (_, index) => {
+    const line = String(index).padStart(3, "0")
+    return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
+  }).join("\n")
+  let patchBytes = 0
+  let capped = false
+
+  return Array.from({ length: fileCount }, (_, index) => {
+    const file = reviewFile(index)
+    const fullPatch = [
+      `diff --git a/${file} b/${file}`,
+      `--- a/${file}`,
+      `+++ b/${file}`,
+      `@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
+      changes,
+    ].join("\n")
+    if (index === 0 && fullPatch.length > patchByteLimit)
+      throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
+    const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
+    if (patch === fullPatch) patchBytes += fullPatch.length
+    else capped = true
+    return {
+      file,
+      patch,
+      additions: linesPerSide,
+      deletions: linesPerSide,
+      status: "modified" as const,
+    }
+  })
+}
+
+function emptyReviewPatch(file: string) {
+  return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
+}
+
+function reviewFile(index: number) {
+  return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
+}
+
+async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
+  const toggle = page.getByRole("button", { name: "Toggle review" })
+  await expect(toggle).toBeVisible()
+  await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
+  await installReviewPaneScalingProbe(page, input)
+  await toggle.click()
+  await page.waitForFunction(
+    () =>
+      (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
+        ?.stableReadyMs !== undefined,
+    undefined,
+    { timeout: completionTimeoutMs },
+  )
+
+  return page.evaluate(() => {
+    const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
+    probe.stop()
+    const startedAt = probe.startedAt!
+    const final = probe.samples.at(-1)!
+    const resources = performance
+      .getEntriesByType("resource")
+      .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
+    const resource = resources.at(-1)
+    const longTasks = probe.longTasks.filter(
+      (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
+    )
+    const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
+
+    return {
+      firstTreeRowMs: probe.firstTreeRowMs ?? null,
+      logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
+      firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
+      stableReadyMs: probe.stableReadyMs ?? null,
+      responseStartMs: resource ? resource.responseStart - startedAt : null,
+      responseEndMs: resource ? resource.responseEnd - startedAt : null,
+      responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
+      treeRows: final.treeRows,
+      logicalRows: final.logicalRows,
+      fileRows: final.fileRows,
+      diffLines: final.diffLines,
+      samples: probe.samples.length,
+      maxFrameGapMs: Math.max(0, ...frameGaps),
+      longTaskCount: longTasks.length,
+      longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
+      maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
+    }
+  })
+}
+
+async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
+  await page.evaluate(
+    ({ expectedFile, expectedRows, stableFrames }) => {
+      let running = true
+      let readyStreak = 0
+      const basename = expectedFile.split("/").at(-1)!
+      const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
+        ? new PerformanceObserver((list) => {
+            probe.longTasks.push(
+              ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
+            )
+          })
+        : undefined
+      const probe: ReviewPaneScalingProbe = {
+        samples: [],
+        frameTimesMs: [],
+        longTasks: [],
+        stop: () => {
+          running = false
+          longTaskObserver?.disconnect()
+        },
+      }
+
+      const sample = (time: number) => {
+        if (!running || probe.startedAt === undefined) return
+        const panel = document.querySelector<HTMLElement>("#review-panel")
+        const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
+        const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
+        const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
+        const header =
+          panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
+        const viewers = panel
+          ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
+          : []
+        const diffLines = viewers.reduce(
+          (sum, viewer) =>
+            sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
+          0,
+        )
+        const observedAtMs = time - probe.startedAt
+        const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
+        const ready =
+          logicalRows === expectedRows &&
+          fileRows.length > 0 &&
+          header.includes(basename) &&
+          viewers.length === 1 &&
+          diffLines > 0
+        const previous = probe.samples.at(-1)
+        const stable =
+          ready &&
+          previous?.ready === true &&
+          previous.logicalRows === logicalRows &&
+          previous.treeRows === rows.length &&
+          previous.fileRows === fileRows.length &&
+          previous.diffLines === diffLines &&
+          previous.header === header
+
+        probe.frameTimesMs.push(observedAtMs)
+        probe.samples.push({
+          observedAtMs,
+          logicalRows,
+          treeRows: rows.length,
+          fileRows: fileRows.length,
+          diffLines,
+          header,
+          ready,
+        })
+        if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
+        if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
+          probe.logicalTreeReadyMs = observedAtMs
+        if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
+        readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
+        if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
+        if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
+      }
+
+      longTaskObserver?.observe({ type: "longtask", buffered: true })
+      document.addEventListener(
+        "click",
+        (event) => {
+          const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
+          if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
+          probe.startedAt = performance.now()
+          performance.mark("opencode.review-pane-scaling.click")
+          requestAnimationFrame(sample)
+        },
+        { capture: true, once: true },
+      )
+      ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
+    },
+    { ...input, stableFrames: readyFrames },
+  )
+}

+ 29 - 11
packages/app/e2e/regression/review-tab-switch.spec.ts

@@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
 const titleA = "Alpha session"
 const titleB = "Beta session"
 const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+const diffs = Array.from({ length: 2_740 }, (_, index) =>
+  fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
+)
 // Marks the review pane DOM node so a remount (fresh node) is detectable.
 const PROBE = "original"
 
@@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
   await expectSessionTitle(page, titleA)
 
   await page.getByRole("button", { name: "Toggle review" }).click()
+  const reviewTab = page.getByRole("tab", { name: /Review/ })
+  const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
+  await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
+  await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
   const review = page.locator('#review-panel [data-component="session-review-v2"]')
   await expectAppVisible(review)
-  await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
+  await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
   await writeProbe(page)
 
   await switchTab(page, titleB)
   await expectSessionTitle(page, titleB)
   await expectAppVisible(review)
+  await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
   expect(await readProbe(page)).toBe(PROBE)
 
   await switchTab(page, titleA)
   await expectSessionTitle(page, titleA)
   await expectAppVisible(review)
+  await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
   expect(await readProbe(page)).toBe(PROBE)
+
+  const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
+  await viewport.hover()
+  await page.mouse.wheel(0, 100_000)
+  await expect
+    .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
+    .toBeLessThanOrEqual(1)
+  await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
 })
 
 type Probed = HTMLElement & { __e2eProbe?: string }
@@ -80,16 +97,7 @@ async function setup(page: Page) {
       default: { providerID: "opencode", modelID: "test" },
     },
     sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
-    vcsDiff: [
-      {
-        file: "src/example.ts",
-        additions: 1,
-        deletions: 1,
-        status: "modified",
-        patch:
-          "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
-      },
-    ],
+    vcsDiff: diffs,
     pageMessages: () => ({ items: [] }),
   })
 
@@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
 function sessionHref(sessionID: string) {
   return `/server/${base64Encode(server)}/session/${sessionID}`
 }
+
+function fileDiff(file: string) {
+  return {
+    file,
+    additions: 1,
+    deletions: 1,
+    status: "modified",
+    patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
+  }
+}

+ 212 - 0
packages/app/e2e/regression/review-terminal-stacked.spec.ts

@@ -0,0 +1,212 @@
+import { expect, test, type Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/ReviewTerminalStacked"
+const projectID = "proj_review_terminal_stacked"
+const sessionID = "ses_review_terminal_stacked"
+const title = "Review terminal stacked"
+const branchDiffs = [
+  fileDiff(".github/actions/setup-bun/action.yml", 7),
+  ...Array.from({ length: 2_739 }, (_, index) =>
+    fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
+  ),
+]
+
+test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
+  test.setTimeout(120_000)
+  await page.setViewportSize({ width: 1400, height: 900 })
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "review-terminal-stacked",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: {
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
+        },
+      ],
+      connected: ["opencode"],
+      default: { providerID: "opencode", modelID: "test" },
+    },
+    sessions: [
+      {
+        id: sessionID,
+        slug: "review-terminal-stacked",
+        projectID,
+        directory,
+        title,
+        version: "dev",
+        time: { created: 1700000000000, updated: 1700000000000 },
+      },
+    ],
+    pageMessages: () => ({ items: [] }),
+  })
+  await page.route(/\/vcs(?:\?.*)?$/, (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
+    }),
+  )
+  await page.route("**/vcs/diff**", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(
+        new URL(route.request().url()).searchParams.get("mode") === "branch"
+          ? branchDiffs
+          : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
+      ),
+    }),
+  )
+  await page.route("**/pty", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
+    }),
+  )
+  await page.route("**/pty/pty_review_terminal", (route) =>
+    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  )
+  await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
+  await page.addInitScript(() => {
+    localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+    localStorage.setItem(
+      "opencode.global.dat:layout",
+      JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
+    )
+  })
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await expectSessionTitle(page, title)
+  await expect(page.locator("#review-panel")).toBeVisible()
+  await expectTree(page, 8, "git-0.ts")
+
+  await selectMode(page, "Git changes", "Branch changes")
+  await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
+  await page.keyboard.press("Control+Backquote")
+  await expect(page.locator("#terminal-panel")).toBeVisible()
+  await expectTree(page, 2_745, "action.yml")
+  await expectStackGeometry(page)
+
+  const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
+  await treeViewport.hover()
+  await page.mouse.wheel(0, 100_000)
+  await expect
+    .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
+    .toBeLessThanOrEqual(1)
+  const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
+  await expect(lastFile).toBeVisible()
+  const bottomGap = await lastFile.evaluate((element) => {
+    const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
+    return viewport.bottom - element.getBoundingClientRect().bottom
+  })
+  expect(bottomGap).toBeGreaterThanOrEqual(0)
+  expect(bottomGap).toBeLessThanOrEqual(16)
+  await selectMode(page, "Branch changes", "Git changes")
+  await expectTree(page, 8, "git-0.ts")
+  await selectMode(page, "Git changes", "Branch changes")
+  await expectTree(page, 2_745, "action.yml")
+
+  const filter = page.getByRole("searchbox", { name: "Filter files" })
+  await filter.fill("generated-2738")
+  await expectTree(page, 1, "generated-2738.ts")
+  await filter.fill("")
+  await expectTree(page, 2_745, "action.yml")
+
+  await page.getByRole("button", { name: "Toggle file tree" }).click()
+  await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
+  await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
+  await page.getByRole("button", { name: "Toggle file tree" }).click()
+  await expectTree(page, 2_745, "action.yml")
+
+  await page.keyboard.press("Control+Backquote")
+  await expect(page.locator("#terminal-panel")).toHaveCount(0)
+  await expectTree(page, 2_745, "action.yml")
+  await page.keyboard.press("Control+Backquote")
+  await expect(page.locator("#terminal-panel")).toBeVisible()
+  await expectTree(page, 2_745, "action.yml")
+
+  await page.getByRole("button", { name: "Toggle review" }).click()
+  await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
+  await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
+  await page.getByRole("button", { name: "Toggle review" }).click()
+  await expectTree(page, 2_745, "action.yml")
+  await page.setViewportSize({ width: 1_000, height: 700 })
+  await expectTree(page, 2_745, "action.yml")
+  await expectStackGeometry(page)
+  await page.setViewportSize({ width: 1_000, height: 120 })
+  await page.setViewportSize({ width: 1_400, height: 900 })
+  await expectTree(page, 2_745, "action.yml")
+  await expectStackGeometry(page)
+})
+
+async function selectMode(page: Page, current: string, next: string) {
+  await page.getByRole("button", { name: current }).click()
+  const option = page.getByRole("option", { name: next })
+  await expect(option).toBeVisible()
+  await option.click()
+}
+
+async function expectTree(page: Page, total: number, file: string) {
+  await expectMountedTree(page, total)
+  await expect(page.getByRole("button", { name: file })).toBeVisible()
+}
+
+async function expectMountedTree(page: Page, total: number) {
+  const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
+  await expect(tree).toHaveAttribute("data-total-rows", String(total))
+  await expect
+    .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
+    .toBeGreaterThan(0)
+  const state = await tree.evaluate((element) => ({
+    root: element.getBoundingClientRect().height,
+    viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
+    rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
+  }))
+  expect(state.viewport).toBeGreaterThan(0)
+  expect(state.root).toBeGreaterThan(0)
+  expect(state.rows).toBeGreaterThan(0)
+  expect(state.rows).toBeLessThanOrEqual(60)
+}
+
+async function expectStackGeometry(page: Page) {
+  const geometry = await page.evaluate(() => {
+    const review = document.querySelector<HTMLElement>("#review-panel")!
+    const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
+    const reviewParent = review.parentElement!.getBoundingClientRect()
+    const terminalParent = terminal.parentElement!.getBoundingClientRect()
+    return {
+      review: review.getBoundingClientRect().height,
+      reviewParent: reviewParent.height,
+      terminal: terminal.getBoundingClientRect().height,
+      terminalParent: terminalParent.height,
+    }
+  })
+  expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
+  expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
+}
+
+function base64Encode(value: string) {
+  return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}
+
+function fileDiff(file: string, additions: number) {
+  return {
+    file,
+    additions,
+    deletions: 0,
+    status: "modified",
+    patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
+  }
+}

+ 46 - 0
packages/app/src/components/file-tree-v2-model.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, test } from "bun:test"
+import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
+
+describe("file tree v2 model", () => {
+  test("builds sorted depth-first rows", () => {
+    const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
+
+    expect(model.total).toBe(8)
+    expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
+      ["docs", "directory", 0],
+      ["docs/guide.md", "file", 1],
+      ["src", "directory", 0],
+      ["src/lib", "directory", 1],
+      ["src/lib/a.ts", "file", 2],
+      ["src/lib/b.ts", "file", 2],
+      ["src/z.ts", "file", 1],
+      ["README.md", "file", 0],
+    ])
+  })
+
+  test("omits descendants of collapsed directories", () => {
+    const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
+
+    expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
+      "src",
+      "src/lib",
+      "src/z.ts",
+    ])
+  })
+
+  test("normalizes separators and duplicate paths", () => {
+    const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
+    const rows = flattenFileTreeV2(model, () => true)
+
+    expect(model.total).toBe(4)
+    expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
+    expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
+  })
+
+  test("supports paths deeper than the legacy recursion limit", () => {
+    const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
+    const model = buildFileTreeV2Model([file])
+
+    expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
+  })
+})

+ 77 - 0
packages/app/src/components/file-tree-v2-model.ts

@@ -0,0 +1,77 @@
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+export type FileTreeV2Model = {
+  children: ReadonlyMap<string, readonly FileTreeV2Node[]>
+  total: number
+}
+
+export type FileTreeV2Node = FileNode & { originalPath: string }
+
+export type FileTreeV2Row = {
+  node: FileTreeV2Node
+  level: number
+}
+
+export function normalizeFileTreeV2Path(value: string) {
+  return value
+    .replaceAll("\\", "/")
+    .replace(/^\/+|\/+$/g, "")
+    .replace(/\/{2,}/g, "/")
+}
+
+export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
+  const nodes = new Map<string, FileTreeV2Node>()
+
+  paths.forEach((value) => {
+    const file = normalizeFileTreeV2Path(value)
+    if (!file) return
+
+    const parts = file.split("/")
+    parts.forEach((name, index) => {
+      const path = parts.slice(0, index + 1).join("/")
+      if (nodes.has(path)) return
+      nodes.set(path, {
+        name,
+        path,
+        absolute: path,
+        type: index === parts.length - 1 ? "file" : "directory",
+        ignored: false,
+        originalPath: index === parts.length - 1 ? value : path,
+      })
+    })
+  })
+
+  const children = new Map<string, FileTreeV2Node[]>()
+  nodes.forEach((node) => {
+    const index = node.path.lastIndexOf("/")
+    const parent = index === -1 ? "" : node.path.slice(0, index)
+    const list = children.get(parent)
+    if (list) list.push(node)
+    else children.set(parent, [node])
+  })
+  children.forEach((nodes) =>
+    nodes.sort((a, b) => {
+      if (a.type !== b.type) return a.type === "directory" ? -1 : 1
+      return a.name.localeCompare(b.name)
+    }),
+  )
+
+  return { children, total: nodes.size }
+}
+
+export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
+  const rows: FileTreeV2Row[] = []
+  const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
+
+  while (stack.length > 0) {
+    const row = stack.pop()!
+    rows.push(row)
+    if (row.node.type !== "directory" || !expanded(row.node.path)) continue
+    const children = model.children.get(row.node.path) ?? []
+    for (let index = children.length - 1; index >= 0; index--) {
+      stack.push({ node: children[index]!, level: row.level + 1 })
+    }
+  }
+
+  return rows
+}

+ 144 - 297
packages/app/src/components/file-tree-v2.tsx

@@ -1,95 +1,26 @@
 import { useFile } from "@/context/file"
-import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import "@opencode-ai/ui/v2/file-tree-v2.css"
 import {
   createEffect,
   createMemo,
+  createSignal,
   For,
-  Match,
-  on,
   Show,
   splitProps,
-  Switch,
-  untrack,
   type ComponentProps,
   type ParentProps,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import type { FileNode } from "@opencode-ai/sdk/v2"
 import { Icon } from "@opencode-ai/ui/v2/icon"
-import {
-  dirsToExpand,
-  pathToFileUrl,
-  shouldListRoot,
-  visibleKind,
-  withFileDragImage,
-  type Filter,
-  type Kind,
-} from "@/components/file-tree"
+import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
+import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
+import { virtualScrollElement } from "@/components/virtual-scroll-element"
 
 export type { Kind } from "@/components/file-tree"
 
-const MAX_DEPTH = 128
-
-function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
-  const nodes = children(path)
-  if (!current) return nodes
-
-  const parent = (item: string) => {
-    const idx = item.lastIndexOf("/")
-    if (idx === -1) return ""
-    return item.slice(0, idx)
-  }
-
-  const leaf = (item: string) => {
-    const idx = item.lastIndexOf("/")
-    return idx === -1 ? item : item.slice(idx + 1)
-  }
-
-  const out = nodes.filter((node) => {
-    if (node.type === "file") return current.files.has(node.path)
-    return current.dirs.has(node.path)
-  })
-
-  const seen = new Set(out.map((node) => node.path))
-
-  for (const dir of current.dirs) {
-    if (parent(dir) !== path) continue
-    if (seen.has(dir)) continue
-    out.push({
-      name: leaf(dir),
-      path: dir,
-      absolute: dir,
-      type: "directory",
-      ignored: false,
-    })
-    seen.add(dir)
-  }
-
-  for (const item of current.files) {
-    if (parent(item) !== path) continue
-    if (seen.has(item)) continue
-    out.push({
-      name: leaf(item),
-      path: item,
-      absolute: item,
-      type: "file",
-      ignored: false,
-    })
-    seen.add(item)
-  }
-
-  out.sort((a, b) => {
-    if (a.type !== b.type) {
-      return a.type === "directory" ? -1 : 1
-    }
-    return a.name.localeCompare(b.name)
-  })
-
-  return out
-}
-
 const INDENT_STEP = 16
 
 function rowPaddingLeft(level: number, type: FileNode["type"]) {
@@ -123,7 +54,6 @@ const FileTreeNodeV2 = (
       active?: string
       draggable: boolean
       kinds?: ReadonlyMap<string, Kind>
-      marks?: Set<string>
       as?: "div" | "button"
     },
 ) => {
@@ -133,18 +63,18 @@ const FileTreeNodeV2 = (
     "active",
     "draggable",
     "kinds",
-    "marks",
     "as",
     "children",
     "class",
     "classList",
   ])
-  const kind = () => visibleKind(local.node, local.kinds, local.marks)
+  const kind = () => local.kinds?.get(local.node.path)
 
   return (
     <Dynamic
       component={local.as ?? "div"}
       data-slot="file-tree-v2-row"
+      data-path={local.node.path}
       data-selected={local.node.path === local.active ? "" : undefined}
       data-ignored={local.node.ignored ? "" : undefined}
       classList={{
@@ -177,244 +107,161 @@ const FileTreeNodeV2 = (
   )
 }
 
-// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
-// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
-// synthesized from that list) for nested content to appear.
+function GuideLines(props: { level: number }) {
+  return (
+    <For each={Array.from({ length: props.level })}>
+      {(_, index) => (
+        <div
+          class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
+          style={`left: ${guideLineLeft(index())}px`}
+        />
+      )}
+    </For>
+  )
+}
+
 export default function FileTreeV2(props: {
-  path: string
   active?: string
-  level?: number
   allowed?: readonly string[]
   kinds?: ReadonlyMap<string, Kind>
   draggable?: boolean
   onFileClick?: (file: FileNode) => void
-
-  _filter?: Filter
-  _marks?: Set<string>
-  _deeps?: Map<string, number>
-  _kinds?: ReadonlyMap<string, Kind>
-  _chain?: readonly string[]
 }) {
   const file = useFile()
-  const level = props.level ?? 0
   const draggable = () => props.draggable ?? true
-
-  const key = (p: string) =>
-    file
-      .normalize(p)
-      .replace(/[\\/]+$/, "")
-      .replaceAll("\\", "/")
-  const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
-
-  const filter = createMemo(() => {
-    if (props._filter) return props._filter
-
-    const allowed = props.allowed
-    if (!allowed) return
-
-    const files = new Set(allowed)
-    const dirs = new Set<string>()
-
-    for (const item of allowed) {
-      const parts = item.split("/")
-      const parents = parts.slice(0, -1)
-      for (const [idx] of parents.entries()) {
-        const dir = parents.slice(0, idx + 1).join("/")
-        if (dir) dirs.add(dir)
-      }
-    }
-
-    return { files, dirs }
-  })
-
-  const marks = createMemo(() => {
-    if (props._marks) return props._marks
-
-    const out = new Set<string>(props.kinds?.keys() ?? [])
-    if (out.size === 0) return
-    return out
-  })
-
-  const kinds = createMemo(() => {
-    if (props._kinds) return props._kinds
-    return props.kinds
-  })
-
-  const deeps = createMemo(() => {
-    if (props._deeps) return props._deeps
-
-    const out = new Map<string, number>()
-
-    const root = props.path
-    if (!(file.tree.state(root)?.expanded ?? false)) return out
-
-    const seen = new Set<string>()
-    const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
-
-    const push = (dir: string, lvl: number) => {
-      const id = key(dir)
-      if (seen.has(id)) return
-      seen.add(id)
-
-      const kids = file.tree
-        .children(dir)
-        .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
-        .map((node) => node.path)
-
-      stack.push({ dir, lvl, i: 0, kids, max: lvl })
-    }
-
-    push(root, level - 1)
-
-    while (stack.length > 0) {
-      const top = stack[stack.length - 1]!
-
-      if (top.i < top.kids.length) {
-        const next = top.kids[top.i]!
-        top.i++
-        push(next, top.lvl + 1)
-        continue
-      }
-
-      out.set(top.dir, top.max)
-      stack.pop()
-
-      const parent = stack[stack.length - 1]
-      if (!parent) continue
-      parent.max = Math.max(parent.max, top.max)
-    }
-
-    return out
+  const active = () => normalizeFileTreeV2Path(props.active ?? "")
+  const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
+  const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
+  const [root, setRoot] = createSignal<HTMLDivElement>()
+  const [focused, setFocused] = createSignal<string>()
+  const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+    get count() {
+      return rows().length
+    },
+    getScrollElement: () => virtualScrollElement(root()),
+    initialRect: { width: 0, height: 600 },
+    estimateSize: () => 28,
+    gap: 2,
+    overscan: 10,
+    get getItemKey() {
+      const current = rows()
+      return (index: number) => current[index]?.node.path ?? index
+    },
+    rangeExtractor: (range) => {
+      const indexes = defaultRangeExtractor(range)
+      const path = focused()
+      const index = path ? rows().findIndex((row) => row.node.path === path) : -1
+      if (index < 0 || indexes.includes(index)) return indexes
+      return [...indexes, index].sort((a, b) => a - b)
+    },
   })
-
   createEffect(() => {
-    const current = filter()
-    const dirs = dirsToExpand({
-      level,
-      filter: current,
-      expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
+    const path = active()
+    if (!path) return
+    const index = rows().findIndex((row) => row.node.path === path)
+    if (index < 0) return
+    queueMicrotask(() => {
+      if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
+      virtualizer.scrollToIndex(index, { align: "auto" })
     })
-    // Nodes come from the `allowed` filter; skip listing so directories that only
-    // exist on the diff's base branch do not each fail with an error toast.
-    for (const dir of dirs) file.tree.expand(dir, { list: false })
   })
-
-  createEffect(
-    on(
-      () => props.path,
-      (path) => {
-        const dir = untrack(() => file.tree.state(path))
-        if (!shouldListRoot({ level, dir })) return
-        void file.tree.list(path)
-      },
-      { defer: false },
-    ),
+  const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
+  const virtualItemByKey = createMemo(
+    () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
   )
-
-  const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
+  const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
 
   return (
-    // group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
-    // an outer group with the same name to widen the hover area.
-    <div data-component="file-tree-v2" class="group/file-tree-v2">
-      <For each={nodes()}>
-        {(node) => {
-          const expanded = () => file.tree.state(node.path)?.expanded ?? false
-          const deep = () => deeps().get(node.path) ?? -1
-          const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
-          return (
-            <Switch>
-              <Match when={node.type === "directory"}>
-                <Collapsible
-                  variant="ghost"
-                  class="w-full"
-                  data-scope="file-tree-v2"
-                  forceMount={false}
-                  open={expanded()}
-                  onOpenChange={(open) =>
-                    open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
-                  }
-                >
-                  <Collapsible.Trigger>
-                    <FileTreeNodeV2
-                      node={node}
-                      level={level}
-                      active={props.active}
-                      draggable={draggable()}
-                      kinds={kinds()}
-                      marks={marks()}
+    <div
+      ref={setRoot}
+      data-component="file-tree-v2"
+      data-total-rows={model().total}
+      class="group/file-tree-v2"
+      style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
+    >
+      <For each={virtualRowKeys()}>
+        {(key) => (
+          <Show when={virtualItemByKey().get(key)}>
+            {(item) => (
+              <div
+                style={{
+                  position: "absolute",
+                  top: "0",
+                  left: "0",
+                  width: "100%",
+                  height: `${item().size}px`,
+                  transform: `translateY(${item().start}px)`,
+                }}
+              >
+                <Show when={rowByKey().get(key as string)}>
+                  {(row) => (
+                    <Show
+                      when={row().node.type === "directory"}
+                      fallback={
+                        <FileTreeNodeV2
+                          node={row().node}
+                          level={row().level}
+                          active={active()}
+                          draggable={draggable()}
+                          kinds={props.kinds}
+                          as="button"
+                          type="button"
+                          class="relative"
+                          onFocus={() => setFocused(row().node.path)}
+                          onBlur={() => setFocused(undefined)}
+                          onClick={() =>
+                            props.onFileClick?.({
+                              ...row().node,
+                              path: row().node.originalPath,
+                              absolute: row().node.originalPath,
+                            })
+                          }
+                        >
+                          <GuideLines level={row().level} />
+                          <Show when={row().level > 0}>
+                            <div class="w-4 shrink-0" />
+                          </Show>
+                          <span class="filetree-iconpair size-4">
+                            <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
+                            <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
+                          </span>
+                        </FileTreeNodeV2>
+                      }
                     >
-                      <div
-                        data-slot="file-tree-v2-chevron"
-                        data-expanded={expanded() ? "" : undefined}
-                        class="size-4 flex items-center justify-center"
-                      >
-                        <Icon name="chevron-down" />
-                      </div>
-                    </FileTreeNodeV2>
-                  </Collapsible.Trigger>
-                  <Show when={hasChildren()}>
-                    <Collapsible.Content class="relative">
-                      <div
-                        classList={{
-                          "absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
-                          "group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
-                          "group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
-                        }}
-                        style={`left: ${guideLineLeft(level)}px`}
-                      />
-                      <Show
-                        when={level < MAX_DEPTH && !chain.includes(key(node.path))}
-                        fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
+                      <FileTreeNodeV2
+                        node={row().node}
+                        level={row().level}
+                        active={active()}
+                        draggable={draggable()}
+                        kinds={props.kinds}
+                        as="button"
+                        type="button"
+                        class="relative"
+                        onFocus={() => setFocused(row().node.path)}
+                        onBlur={() => setFocused(undefined)}
+                        aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
+                        onClick={() =>
+                          file.tree.state(row().node.path)?.expanded === false
+                            ? file.tree.expand(row().node.path, { list: false })
+                            : file.tree.collapse(row().node.path)
+                        }
                       >
-                        <FileTreeV2
-                          path={node.path}
-                          level={level + 1}
-                          allowed={props.allowed}
-                          kinds={props.kinds}
-                          active={props.active}
-                          draggable={props.draggable}
-                          onFileClick={props.onFileClick}
-                          _filter={filter()}
-                          _marks={marks()}
-                          _deeps={deeps()}
-                          _kinds={kinds()}
-                          _chain={chain}
-                        />
-                      </Show>
-                    </Collapsible.Content>
-                  </Show>
-                </Collapsible>
-              </Match>
-              <Match when={node.type === "file"}>
-                <FileTreeNodeV2
-                  node={node}
-                  level={level}
-                  active={props.active}
-                  draggable={draggable()}
-                  kinds={kinds()}
-                  marks={marks()}
-                  as="button"
-                  type="button"
-                  onClick={() => props.onFileClick?.(node)}
-                >
-                  <Show when={level > 0}>
-                    <div class="w-4 shrink-0" />
-                  </Show>
-                  <Show
-                    when={!node.ignored}
-                    fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
-                  >
-                    <span class="filetree-iconpair size-4">
-                      <FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
-                      <FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
-                    </span>
-                  </Show>
-                </FileTreeNodeV2>
-              </Match>
-            </Switch>
-          )
-        }}
+                        <GuideLines level={row().level} />
+                        <div
+                          data-slot="file-tree-v2-chevron"
+                          data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
+                          class="size-4 flex items-center justify-center"
+                        >
+                          <Icon name="chevron-down" />
+                        </div>
+                      </FileTreeNodeV2>
+                    </Show>
+                  )}
+                </Show>
+              </div>
+            )}
+          </Show>
+        )}
       </For>
     </div>
   )

+ 18 - 0
packages/app/src/components/virtual-scroll-element.test.ts

@@ -0,0 +1,18 @@
+import { expect, test } from "bun:test"
+import { virtualScrollElement } from "./virtual-scroll-element"
+
+test("resolves the connected viewport that owns the virtual root", () => {
+  const stale = document.createElement("div")
+  stale.className = "scroll-view__viewport"
+  const viewport = document.createElement("div")
+  viewport.className = "scroll-view__viewport"
+  const root = document.createElement("div")
+  viewport.append(root)
+  document.body.append(viewport)
+
+  expect(virtualScrollElement(root)).toBe(viewport)
+  expect(virtualScrollElement(root)).not.toBe(stale)
+
+  viewport.remove()
+  expect(virtualScrollElement(root)).toBeNull()
+})

+ 4 - 0
packages/app/src/components/virtual-scroll-element.ts

@@ -0,0 +1,4 @@
+export function virtualScrollElement(root: HTMLElement | undefined) {
+  if (!root?.isConnected) return null
+  return root.closest<HTMLDivElement>(".scroll-view__viewport")
+}

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

@@ -2157,6 +2157,7 @@ export default function Page() {
             diffsReady={reviewReady}
             empty={reviewEmptyText}
             hasReview={hasReview}
+            reviewHasFocusableContent={hasReview}
             reviewCount={reviewCount}
             reviewPanel={reviewPanel}
             activeDiff={tree.activeDiff}
@@ -2168,14 +2169,20 @@ export default function Page() {
         <Show when={newSessionDesign()}>
           <Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
             <div class="min-w-0 h-full flex flex-1 flex-col">
-              <Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
-                <div class="min-h-0 flex-1">
+              <Show when={isDesktop()}>
+                <div
+                  classList={{
+                    "min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
+                    "size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
+                  }}
+                >
                   <SessionSidePanel
                     canReview={canReview}
                     diffs={reviewDiffs}
                     diffsReady={reviewReady}
                     empty={reviewEmptyText}
                     hasReview={hasReview}
+                    reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
                     reviewCount={reviewCount}
                     reviewPanel={reviewPanelV2}
                     activeDiff={tree.activeDiff}
@@ -2204,7 +2211,12 @@ export default function Page() {
                 </div>
               </Show>
               <Show when={terminalOpen()}>
-                <div class="min-h-0 flex-1">
+                <div
+                  classList={{
+                    "min-h-0 shrink-0": desktopV2PanelLayout().stacked,
+                    "min-h-0 flex-1": !desktopV2PanelLayout().stacked,
+                  }}
+                >
                   <TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
                 </div>
               </Show>

+ 30 - 7
packages/app/src/pages/session/session-side-panel.tsx

@@ -14,6 +14,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 
 import FileTree from "@/components/file-tree"
 import { SessionContextUsage } from "@/components/session-context-usage"
+
+const reviewTabID = "session-side-panel-review-tab"
+const reviewTabPanelID = "session-side-panel-review-tabpanel"
 import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
 import { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
@@ -45,6 +48,7 @@ export function SessionSidePanel(props: {
   diffsReady: () => boolean
   empty: () => string
   hasReview: () => boolean
+  reviewHasFocusableContent: () => boolean
   reviewCount: () => number
   reviewPanel: () => JSX.Element
   activeDiff?: string
@@ -75,6 +79,7 @@ export function SessionSidePanel(props: {
       }),
   )
   const open = createMemo(() => reviewOpen() || fileOpen())
+  const rendered = createMemo<boolean>((previous) => previous || open(), false)
   const reviewTab = createMemo(() => isDesktop())
   const panelWidth = createMemo(() => {
     if (!open()) return "0px"
@@ -155,6 +160,10 @@ export function SessionSidePanel(props: {
   const openedTabs = tabState.openedTabs
   const activeTab = tabState.activeTab
   const activeFileTab = tabState.activeFileTab
+  const reviewContentRendered = createMemo<boolean>(
+    (previous) => previous || (reviewOpen() && activeTab() === "review"),
+    false,
+  )
 
   const fileTreeTab = () => layout.fileTree.tab()
 
@@ -223,7 +232,7 @@ export function SessionSidePanel(props: {
         class="relative min-w-0 flex overflow-hidden bg-background-base"
         classList={{
           "h-full shrink-0": !props.stacked,
-          "min-h-0 flex-1": props.stacked,
+          "h-full min-h-0": props.stacked,
           "pointer-events-none": !open(),
           "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
             !props.size.active() && !props.reviewSnap,
@@ -232,7 +241,7 @@ export function SessionSidePanel(props: {
         }}
         style={{ width: panelWidth() }}
       >
-        <Show when={open()}>
+        <Show when={rendered()}>
           <div
             class="size-full flex"
             classList={{
@@ -265,7 +274,11 @@ export function SessionSidePanel(props: {
                         }}
                       >
                         <Show when={reviewTab() && props.canReview()}>
-                          <Tabs.Trigger value="review">
+                          <Tabs.Trigger
+                            value="review"
+                            id={reviewTabID}
+                            aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
+                          >
                             <div class="flex items-center gap-1.5">
                               <div>{language.t("session.tab.review")}</div>
                               <Show when={props.hasReview()}>
@@ -328,10 +341,20 @@ export function SessionSidePanel(props: {
                       </Tabs.List>
                     </div>
 
-                    <Show when={reviewTab() && props.canReview()}>
-                      <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
-                        <Show when={reviewOpen() && activeTab() === "review"}>{props.reviewPanel()}</Show>
-                      </Tabs.Content>
+                    <Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
+                      <div
+                        id={reviewTabPanelID}
+                        role="tabpanel"
+                        aria-labelledby={reviewTabID}
+                        aria-hidden={activeTab() !== "review"}
+                        inert={activeTab() !== "review"}
+                        tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
+                        data-slot="tabs-content"
+                        class="flex flex-col h-full overflow-hidden contain-strict"
+                        classList={{ hidden: activeTab() !== "review" }}
+                      >
+                        {props.reviewPanel()}
+                      </div>
                     </Show>
 
                     <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">

+ 7 - 0
packages/app/src/pages/session/v2/review-diff-kinds.test.ts

@@ -12,6 +12,13 @@ describe("reviewDiffKinds", () => {
     expect(kinds.get("src/b.ts")).toBe("del")
     expect(kinds.get("src")).toBe("mix")
   })
+
+  test("normalizes file and directory paths", () => {
+    const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }])
+
+    expect(kinds.get("src/lib/a.ts")).toBe("mix")
+    expect(kinds.get("src/lib")).toBe("mix")
+  })
 })
 
 describe("filterReviewFiles", () => {

+ 2 - 1
packages/app/src/pages/session/v2/review-diff-kinds.ts

@@ -1,10 +1,11 @@
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import type { Kind } from "@/components/file-tree-v2"
+import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
 
 export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
 
 export function normalizePath(p: string) {
-  return p.replaceAll("\\", "/").replace(/\/+$/, "")
+  return normalizeFileTreeV2Path(p)
 }
 
 export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {

+ 0 - 1
packages/app/src/pages/session/v2/review-panel-v2.tsx

@@ -203,7 +203,6 @@ function ReviewPanelV2Sidebar(props: {
           when={props.searching()}
           fallback={
             <FileTreeV2
-              path=""
               allowed={props.filteredFiles()}
               kinds={props.kinds()}
               draggable={false}

+ 93 - 46
packages/app/src/pages/session/v2/session-file-list-v2.tsx

@@ -1,9 +1,11 @@
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import "@opencode-ai/ui/v2/file-tree-v2.css"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import { createEffect, For, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
 import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
 import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
+import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+import { virtualScrollElement } from "@/components/virtual-scroll-element"
 
 // Drives the highlight/selection of the flat search-result list from the filter
 // input's keyboard events.
@@ -45,62 +47,107 @@ export function SessionFileListV2(props: {
 }) {
   const active = () => normalizePath(props.active ?? "")
   const highlighted = () => normalizePath(props.highlighted ?? "")
-  let rootRef: HTMLDivElement | undefined
+  const normalized = createMemo(() => props.files.map(normalizePath))
+  const [root, setRoot] = createSignal<HTMLDivElement>()
+  const [focused, setFocused] = createSignal<string>()
+  const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+    get count() {
+      return props.files.length
+    },
+    getScrollElement: () => virtualScrollElement(root()),
+    initialRect: { width: 0, height: 600 },
+    estimateSize: () => 28,
+    gap: 2,
+    overscan: 10,
+    get getItemKey() {
+      const files = props.files
+      return (index: number) => files[index] ?? index
+    },
+    rangeExtractor: (range) => {
+      const indexes = defaultRangeExtractor(range)
+      const path = focused()
+      const index = path ? props.files.indexOf(path) : -1
+      if (index < 0 || indexes.includes(index)) return indexes
+      return [...indexes, index].sort((a, b) => a - b)
+    },
+  })
 
   createEffect(() => {
-    highlighted()
-    if (!rootRef) return
+    const index = normalized().indexOf(highlighted())
+    if (index < 0) return
     queueMicrotask(() => {
-      const row = rootRef?.querySelector<HTMLElement>('[data-slot="file-tree-v2-row"][data-highlighted]')
-      row?.scrollIntoView({ block: "nearest" })
+      if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
+      virtualizer.scrollToIndex(index, { align: "auto" })
     })
   })
+  const virtualItemByKey = createMemo(
+    () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
+  )
+  const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
 
   return (
     <div
-      ref={(el) => {
-        rootRef = el
-      }}
+      ref={setRoot}
       data-component="file-tree-v2"
+      data-total-rows={props.files.length}
+      style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
     >
-      <For each={props.files}>
-        {(path) => {
-          const normalized = normalizePath(path)
-          const selected = () => {
-            if (highlighted()) return highlighted() === normalized
-            return active() === normalized
-          }
-          const highlightedRow = () => highlighted() === normalized
-          const kind = () => props.kinds?.get(normalized)
-          const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
-          const filename = () => getFilename(normalized)
+      <For each={virtualRowKeys()}>
+        {(key) => {
+          const path = key as string
+          const value = normalizePath(path)
+          const selected = () => (highlighted() ? highlighted() === value : active() === value)
+          const highlightedRow = () => highlighted() === value
+          const kind = () => props.kinds?.get(value)
+          const directory = () => (value.includes("/") ? getDirectory(value) : undefined)
+          const filename = () => getFilename(value)
           return (
-            <button
-              type="button"
-              data-slot="file-tree-v2-row"
-              data-selected={selected() ? "" : undefined}
-              data-highlighted={highlightedRow() ? "" : undefined}
-              style="padding-left: 8px"
-              onClick={() => props.onFileClick(path)}
-            >
-              <span class="filetree-iconpair size-4">
-                <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
-                <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
-              </span>
-              <span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
-                <Show when={directory()}>
-                  {(value) => <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>}
-                </Show>
-                <span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
-              </span>
-              <Show when={kind()}>
-                {(value) => (
-                  <span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
-                    {kindLabel(value())}
-                  </span>
-                )}
-              </Show>
-            </button>
+            <Show when={virtualItemByKey().get(key)}>
+              {(item) => (
+                <div
+                  style={{
+                    position: "absolute",
+                    top: "0",
+                    left: "0",
+                    width: "100%",
+                    height: `${item().size}px`,
+                    transform: `translateY(${item().start}px)`,
+                  }}
+                >
+                  <button
+                    type="button"
+                    data-slot="file-tree-v2-row"
+                    data-path={path}
+                    data-selected={selected() ? "" : undefined}
+                    data-highlighted={highlightedRow() ? "" : undefined}
+                    style="padding-left: 8px"
+                    onFocus={() => setFocused(path)}
+                    onBlur={() => setFocused(undefined)}
+                    onClick={() => props.onFileClick(path)}
+                  >
+                    <span class="filetree-iconpair size-4">
+                      <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
+                      <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
+                    </span>
+                    <span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
+                      <Show when={directory()}>
+                        {(value) => (
+                          <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
+                        )}
+                      </Show>
+                      <span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
+                    </span>
+                    <Show when={kind()}>
+                      {(value) => (
+                        <span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
+                          {kindLabel(value())}
+                        </span>
+                      )}
+                    </Show>
+                  </button>
+                </div>
+              )}
+            </Show>
           )
         }}
       </For>

+ 15 - 0
packages/app/test-browser/solid-virtual.test.ts

@@ -27,6 +27,21 @@ test("reactive count updates preserve measured row sizes", () => {
   })
 })
 
+test("initial rect projects rows before a scroll element connects", () => {
+  createRoot((dispose) => {
+    const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+      count: 100,
+      getScrollElement: () => null,
+      estimateSize: () => 28,
+      initialRect: { width: 0, height: 600 },
+      overscan: 10,
+    })
+
+    expect(virtualizer.getVirtualItems().length).toBeGreaterThan(0)
+    dispose()
+  })
+})
+
 test("logical scroll offset includes pending measurement adjustments", () => {
   createRoot((dispose) => {
     const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({

+ 13 - 0
packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts

@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
+
+describe("shouldVirtualizeReviewDiff", () => {
+  test("renders small diffs directly", () => {
+    expect(shouldVirtualizeReviewDiff({ additionLines: 500, deletionLines: 500 })).toBe(false)
+  })
+
+  test("virtualizes large diffs", () => {
+    expect(shouldVirtualizeReviewDiff({ additionLines: 501, deletionLines: 1 })).toBe(true)
+    expect(shouldVirtualizeReviewDiff({ additionLines: 1, deletionLines: 501 })).toBe(true)
+  })
+})

+ 5 - 0
packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts

@@ -0,0 +1,5 @@
+const lineThreshold = 500
+
+export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) {
+  return Math.max(input.additionLines, input.deletionLines) > lineThreshold
+}

+ 5 - 0
packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx

@@ -22,6 +22,7 @@ import type {
 } from "../../components/session-review"
 import type { SessionReviewExpandMode } from "./session-review-v2"
 import { createLineCommentControllerV2 } from "./line-comment-annotations-v2"
+import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
 import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
 import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
 import "./session-review-v2.css"
@@ -219,6 +220,10 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
       preloadedDiff={view().preloaded}
       diffStyle={props.diffStyle}
       expandUnchanged={expandUnchanged()}
+      virtualize={shouldVirtualizeReviewDiff({
+        additionLines: view().fileDiff.additionLines.length,
+        deletionLines: view().fileDiff.deletionLines.length,
+      })}
       hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
       enableLineSelection={lineCommentsEnabled()}
       enableGutterUtility={lineCommentsEnabled()}

+ 47 - 38
packages/session-ui/src/v2/components/session-review-v2.tsx

@@ -11,6 +11,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
+import { getWorkerPool } from "../../pierre/worker"
 import "./session-review-v2.css"
 
 export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
@@ -48,6 +49,7 @@ export type SessionReviewV2SidebarProps = {
   onWidthChange?: (width: number) => void
   minWidth?: number
   maxWidth?: number
+  viewportRef?: (element: HTMLDivElement) => void
   children?: JSX.Element
 }
 
@@ -74,44 +76,47 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
         inert={!props.open}
         style={{ width: props.open ? `${width()}px` : "0px" }}
       >
-        <Show when={props.open}>
-          <div data-slot="session-review-v2-sidebar-header">
-            <div data-slot="session-review-v2-sidebar-title">{props.title}</div>
-            {props.stats}
-          </div>
-          <div data-slot="session-review-v2-sidebar-filter">
-            <TextInputV2
-              type="search"
-              value={props.filter}
-              onInput={(event) => props.onFilterChange(event.currentTarget.value)}
-              onKeyDown={props.onFilterKeyDown}
-              showClearButton={props.filter.length > 0}
-              clearLabel={i18n.t("ui.list.clearFilter")}
-              onClearClick={() => props.onFilterChange("")}
-              placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
-              aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
-              leadingIcon={
-                <svg
-                  width="14"
-                  height="14"
-                  viewBox="0 0 14 14"
-                  fill="none"
-                  xmlns="http://www.w3.org/2000/svg"
-                  aria-hidden="true"
-                >
-                  <path
-                    d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
-                    stroke="currentColor"
-                    stroke-linecap="square"
-                  />
-                </svg>
-              }
-            />
-          </div>
-          <ScrollView data-slot="session-review-v2-sidebar-tree" class="group/file-tree-v2" thumbVisibility="scroll">
-            {props.children}
-          </ScrollView>
-        </Show>
+        <div data-slot="session-review-v2-sidebar-header">
+          <div data-slot="session-review-v2-sidebar-title">{props.title}</div>
+          {props.stats}
+        </div>
+        <div data-slot="session-review-v2-sidebar-filter">
+          <TextInputV2
+            type="search"
+            value={props.filter}
+            onInput={(event) => props.onFilterChange(event.currentTarget.value)}
+            onKeyDown={props.onFilterKeyDown}
+            showClearButton={props.filter.length > 0}
+            clearLabel={i18n.t("ui.list.clearFilter")}
+            onClearClick={() => props.onFilterChange("")}
+            placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
+            aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
+            leadingIcon={
+              <svg
+                width="14"
+                height="14"
+                viewBox="0 0 14 14"
+                fill="none"
+                xmlns="http://www.w3.org/2000/svg"
+                aria-hidden="true"
+              >
+                <path
+                  d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
+                  stroke="currentColor"
+                  stroke-linecap="square"
+                />
+              </svg>
+            }
+          />
+        </div>
+        <ScrollView
+          data-slot="session-review-v2-sidebar-tree"
+          class="group/file-tree-v2"
+          thumbVisibility="scroll"
+          viewportRef={props.viewportRef}
+        >
+          {props.children}
+        </ScrollView>
       </aside>
       <Show when={props.open && props.onWidthChange}>
         <div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}>
@@ -131,6 +136,10 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
 export function SessionReviewV2(props: SessionReviewV2Props) {
   const i18n = useI18n()
 
+  createEffect(() => {
+    getWorkerPool(props.diffStyle)
+  })
+
   const fileIndex = () => {
     const files = props.files
     if (files.length === 0) return -1