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

feat(app): add inline file browser tabs (#35829)

Luke Parker 2 месяцев назад
Родитель
Сommit
62de2b3695

+ 146 - 0
packages/app/e2e/regression/review-open-file.spec.ts

@@ -0,0 +1,146 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/ReviewOpenFile"
+const projectID = "proj_review_open_file"
+const sessionID = "ses_review_open_file"
+const title = "Review open file"
+const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+
+test.use({ viewport: { width: 1440, height: 900 } })
+
+test("opens and searches project files inline", async ({ page }) => {
+  const searches: { query: string; dirs?: string; limit?: number }[] = []
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "open-file-project",
+      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: sessionID,
+        projectID,
+        directory,
+        title,
+        version: "dev",
+        time: { created: 1700000000000, updated: 1700000000000 },
+      },
+    ],
+    vcsDiff: [fileDiff("src/changed.ts")],
+    fileList: (path) => {
+      if (path) return []
+      return [
+        fileNode("README.md"),
+        { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
+      ]
+    },
+    fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
+    findFiles: (input) => {
+      searches.push(input)
+      return input.query === "nested" ? ["src/nested.ts"] : []
+    },
+    pageMessages: () => ({ items: [] }),
+  })
+  await page.addInitScript(
+    ({ directory, server, sessionID }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem(
+        "opencode.global.dat:server",
+        JSON.stringify({
+          projects: { local: [{ worktree: directory, expanded: true }] },
+          lastProject: { local: directory },
+        }),
+      )
+      localStorage.setItem(
+        "opencode.global.dat:layout",
+        JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
+      )
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
+      )
+    },
+    { directory, server, sessionID },
+  )
+
+  await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
+  await expectSessionTitle(page, title)
+
+  const panel = page.locator("#review-panel")
+  const contextButton = page.getByRole("button", { name: "View context usage" })
+  await contextButton.click()
+  await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
+  await panel.getByRole("button", { name: "Open file" }).click()
+  await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
+  await contextButton.click()
+  await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
+  await panel.getByRole("button", { name: "Open file" }).click()
+  const filter = panel.getByRole("combobox", { name: "Filter files" })
+  await expect(filter).toBeFocused()
+  await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
+  await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
+
+  await panel.getByRole("button", { name: "README.md" }).click()
+  await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
+  await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
+
+  await panel.getByRole("button", { name: "Open file" }).click()
+  await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
+  await filter.fill("nested")
+  const result = panel.getByRole("option", { name: /nested\.ts/ })
+  await expect(result).toBeVisible()
+  const resultID = await result.getAttribute("id")
+  expect(resultID).toBeTruthy()
+  await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
+  await filter.press("Enter")
+  await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
+  await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
+  expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
+
+  await panel.getByRole("button", { name: "Open file" }).click()
+  await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
+  await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
+  await page.keyboard.press("Control+w")
+  await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
+  await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
+})
+
+function fileNode(path: string) {
+  return {
+    name: path,
+    path,
+    absolute: `${directory}/${path}`,
+    type: "file",
+    ignored: false,
+  }
+}
+
+function fileDiff(file: string) {
+  return {
+    file,
+    before: "before\n",
+    after: "after\n",
+    additions: 1,
+    deletions: 1,
+    status: "modified",
+  }
+}

+ 10 - 0
packages/app/e2e/utils/mock-server.ts

@@ -22,6 +22,7 @@ export interface MockServerConfig {
   questions?: unknown[] | (() => unknown[])
   fileList?: (path: string) => unknown | Promise<unknown>
   fileContent?: (path: string) => unknown | Promise<unknown>
+  findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
   sessionStatus?: unknown
 }
 
@@ -66,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
     if (path === "/file/content" && config.fileContent)
       return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
+    if (path === "/find/file" && config.findFiles)
+      return json(
+        route,
+        await config.findFiles({
+          query: url.searchParams.get("query") ?? "",
+          dirs: url.searchParams.get("dirs") ?? undefined,
+          limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
+        }),
+      )
     if (path === "/api/reference")
       return json(route, {
         location: {

+ 3 - 0
packages/app/src/components/file-tree.tsx

@@ -201,6 +201,7 @@ export default function FileTree(props: {
   kinds?: ReadonlyMap<string, Kind>
   draggable?: boolean
   onFileClick?: (file: FileNode) => void
+  onFileDoubleClick?: (file: FileNode) => void
 
   _filter?: Filter
   _marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
                         active={props.active}
                         draggable={props.draggable}
                         onFileClick={props.onFileClick}
+                        onFileDoubleClick={props.onFileDoubleClick}
                         _filter={filter()}
                         _marks={marks()}
                         _deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
                   as="button"
                   type="button"
                   onClick={() => props.onFileClick?.(node)}
+                  onDblClick={() => props.onFileDoubleClick?.(node)}
                 >
                   <div class="w-4 shrink-0" />
                   <Switch>

+ 5 - 0
packages/app/src/components/session-context-usage.tsx

@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
 import { Button } from "@opencode-ai/ui/button"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { createMediaQuery } from "@solid-primitives/media"
 
 import { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
@@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk"
 import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
+import { useSettings } from "@/context/settings"
 
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const layout = useLayout()
   const language = useLanguage()
   const sdk = useSDK()
+  const settings = useSettings()
   const providers = useProviders(() => sdk().directory)
   const { params, tabs, view } = useSessionLayout()
+  const isDesktop = createMediaQuery("(min-width: 768px)")
 
   const variant = createMemo(() => props.variant ?? "button")
   const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
     tabs,
     pathFromTab: file.pathFromTab,
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+    fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
   })
   const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))

+ 12 - 4
packages/app/src/components/session/session-sortable-tab.tsx

@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useCommand } from "@/context/command"
 
-export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
+export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
   return (
     <div class="flex items-center gap-x-1.5 min-w-0">
       <Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
           <FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
         </span>
       </Show>
-      <span class="text-14-medium truncate">{getFilename(props.path)}</span>
+      <span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
+        {getFilename(props.path)}
+      </span>
     </div>
   )
 }
 
-export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
+export function SortableTab(props: {
+  tab: string
+  temporary?: boolean
+  onTabClose: (tab: string) => void
+  onTabDoubleClick?: (tab: string) => void
+}): JSX.Element {
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
   const content = createMemo(() => {
     const value = path()
     if (!value) return
-    return <FileVisual path={value} />
+    return <FileVisual path={value} temporary={props.temporary} />
   })
   return (
     <div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
           }
           hideCloseButton
           onMiddleClick={() => props.onTabClose(props.tab)}
+          onDblClick={() => props.onTabDoubleClick?.(props.tab)}
         >
           <Show when={content()}>{(value) => value()}</Show>
         </Tabs.Trigger>

+ 8 - 4
packages/app/src/context/file.tsx

@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       return promise
     }
 
-    const search = (query: string, dirs: "true" | "false") =>
+    const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
       sdk()
-        .client.find.files({ query, dirs })
+        .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
         .then(
           (x) => (x.data ?? []).map(path.normalize),
-          () => [],
+          (error) => {
+            if (options?.signal?.aborted) throw error
+            return []
+          },
         )
 
     const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       setScrollLeft,
       selectedLines,
       setSelectedLines,
-      searchFiles: (query: string) => search(query, "false"),
+      searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
+        search(query, "false", options),
       searchFilesAndDirectories: (query: string) => search(query, "true"),
     }
   },

+ 82 - 0
packages/app/src/context/layout-tabs.test.ts

@@ -0,0 +1,82 @@
+import { describe, expect, test } from "bun:test"
+import {
+  SESSION_OPEN_FILE_TAB,
+  closeSessionTab,
+  openSessionTab,
+  previewSessionTab,
+  type SessionTabState,
+} from "./layout-tabs"
+
+const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
+  tabs: { all, active },
+  preview,
+})
+
+describe("previewSessionTab", () => {
+  test("appends the Open File placeholder", () => {
+    expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
+      state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
+    )
+  })
+
+  test("replaces the current preview in place", () => {
+    expect(
+      previewSessionTab(
+        state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
+        "file://a.ts",
+      ),
+    ).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
+  })
+
+  test("activates a durable tab without duplicating it", () => {
+    expect(
+      previewSessionTab(
+        state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
+        "file://b.ts",
+      ),
+    ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
+  })
+
+  test("replaces a restored Open File placeholder", () => {
+    expect(
+      previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
+    ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
+  })
+})
+
+describe("openSessionTab", () => {
+  test("pins the current preview", () => {
+    expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
+      state(["file://a.ts"], "file://a.ts"),
+    )
+  })
+
+  test("replaces a preview with a directly opened file", () => {
+    expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
+      state(["file://b.ts"], "file://b.ts"),
+    )
+  })
+
+  test("keeps the preview when switching to Review", () => {
+    expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
+      state(["file://a.ts"], "review", "file://a.ts"),
+    )
+  })
+
+  test("replaces a restored Open File placeholder with a direct open", () => {
+    expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
+      state(["file://a.ts", "file://b.ts"], "file://b.ts"),
+    )
+  })
+})
+
+describe("closeSessionTab", () => {
+  test("clears preview metadata and selects the left neighbor", () => {
+    expect(
+      closeSessionTab(
+        state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
+        "file://b.ts",
+      ),
+    ).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
+  })
+})

+ 103 - 0
packages/app/src/context/layout-tabs.ts

@@ -0,0 +1,103 @@
+export const SESSION_OPEN_FILE_TAB = "open-file"
+
+export type SessionTabs = {
+  active?: string
+  all: string[]
+}
+
+export type SessionTabState = {
+  tabs: SessionTabs
+  preview?: string
+}
+
+const sessionTabPreview = (current: SessionTabState) =>
+  current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
+
+export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
+  const preview = sessionTabPreview(current)
+  const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
+  const existingIndex = current.tabs.all.indexOf(tab)
+
+  if (existingIndex !== -1) {
+    if (previewIndex === -1 || preview === tab) {
+      return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
+    }
+    return {
+      tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
+    }
+  }
+
+  if (previewIndex === -1) {
+    return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
+  }
+
+  return {
+    tabs: {
+      all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
+      active: tab,
+    },
+    preview: tab,
+  }
+}
+
+export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
+  const preview = sessionTabPreview(current)
+  if (tab === "review") {
+    return {
+      tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
+      preview,
+    }
+  }
+
+  if (tab === "context") {
+    return {
+      tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
+      preview,
+    }
+  }
+
+  const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
+  const existingIndex = current.tabs.all.indexOf(tab)
+  if (existingIndex !== -1) {
+    if (previewIndex === -1 || preview === tab) {
+      return { tabs: { all: current.tabs.all, active: tab } }
+    }
+    return {
+      tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
+    }
+  }
+
+  if (previewIndex === -1) {
+    return { tabs: { all: [...current.tabs.all, tab], active: tab } }
+  }
+
+  return {
+    tabs: {
+      all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
+      active: tab,
+    },
+  }
+}
+
+export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
+  if (tab === "review") {
+    if (current.tabs.active !== tab) return current
+    return {
+      tabs: { all: current.tabs.all, active: current.tabs.all[0] },
+      preview: current.preview,
+    }
+  }
+
+  const all = current.tabs.all.filter((item) => item !== tab)
+  const preview = current.preview === tab ? undefined : current.preview
+  if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
+
+  const index = current.tabs.all.indexOf(tab)
+  return {
+    tabs: {
+      all,
+      active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
+    },
+    preview,
+  }
+}

+ 45 - 39
packages/app/src/context/layout.tsx

@@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
 import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
 import { requireServerKey } from "@/utils/session-route"
 import { type DraftTab, useTabs } from "./tabs"
+import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
 
 export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
 
@@ -55,11 +56,6 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
   return "gray"
 }
 
-type SessionTabs = {
-  active?: string
-  all: string[]
-}
-
 type SessionView = {
   scroll: Record<string, SessionScroll>
   reviewOpen?: string[]
@@ -87,14 +83,6 @@ export type LayoutRoute =
   | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
   | { type: "session"; sessionId: string; server?: ServerConnection.Key }
 
-function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
-  const all = current?.all ?? []
-  if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
-  if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
-  if (!all.includes(tab)) return { all: [...all, tab], active: tab }
-  return { all, active: tab }
-}
-
 const sessionPath = (key: string) => {
   const dir = SessionStateKey.route(key).split("/")[0]
   if (!dir) return
@@ -308,6 +296,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     )
     const [ephemeral, setEphemeral] = createStore({
       reviewPanelSource: "other" as ReviewPanelSource,
+      sessionTabPreview: {} as Record<string, string | undefined>,
     })
 
     const MAX_SESSION_KEYS = 50
@@ -366,6 +355,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
 
       scroll.drop(drop)
       dropSessionState(drop)
+      setEphemeral(
+        "sessionTabPreview",
+        produce((draft) => {
+          for (const key of drop) delete draft[key]
+        }),
+      )
 
       for (const key of drop) {
         usage.used.delete(key)
@@ -945,10 +940,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
         const normalize = (tab: string) => normalizeSessionTab(path(), tab)
         const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
+        const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
+          batch(() => {
+            setStore("sessionTabs", session, next.tabs)
+            setEphemeral("sessionTabPreview", session, next.preview)
+          })
+        }
         return {
           tabs,
           active: createMemo(() => tabs().active),
           all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
+          preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
           setActive(tab: string | undefined) {
             const session = key()
             const next = tab ? normalize(tab) : tab
@@ -961,40 +963,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setAll(all: string[]) {
             const session = key()
             const next = normalizeAll(all).filter((tab) => tab !== "review")
-            if (!store.sessionTabs[session]) {
-              setStore("sessionTabs", session, { all: next, active: undefined })
-            } else {
-              setStore("sessionTabs", session, "all", next)
-            }
+            batch(() => {
+              if (!store.sessionTabs[session]) {
+                setStore("sessionTabs", session, { all: next, active: undefined })
+              } else {
+                setStore("sessionTabs", session, "all", next)
+              }
+              const preview = ephemeral.sessionTabPreview[session]
+              if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
+            })
           },
           async open(tab: string) {
             const session = key()
-            const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
-            setStore("sessionTabs", session, next)
+            apply(
+              session,
+              openSessionTab(
+                { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
+                normalize(tab),
+              ),
+            )
+          },
+          previewTab(tab: string) {
+            const session = key()
+            apply(
+              session,
+              previewSessionTab(
+                { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
+                normalize(tab),
+              ),
+            )
           },
           close(tab: string) {
             const session = key()
             const current = store.sessionTabs[session]
             if (!current) return
-
-            if (tab === "review") {
-              if (current.active !== tab) return
-              setStore("sessionTabs", session, "active", current.all[0])
-              return
-            }
-
-            const all = current.all.filter((x) => x !== tab)
-            if (current.active !== tab) {
-              setStore("sessionTabs", session, "all", all)
-              return
-            }
-
-            const index = current.all.findIndex((f) => f === tab)
-            const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
-            batch(() => {
-              setStore("sessionTabs", session, "all", all)
-              setStore("sessionTabs", session, "active", next)
-            })
+            apply(
+              session,
+              closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
+            )
           },
           move(tab: string, to: number) {
             const session = key()

+ 2 - 0
packages/app/src/pages/session.tsx

@@ -1019,6 +1019,7 @@ export default function Page() {
     setActiveMessage,
     focusInput,
     review: reviewTab,
+    fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
   })
 
   const openReviewFile = createOpenReviewFile({
@@ -2177,6 +2178,7 @@ export default function Page() {
                     reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
                     reviewCount={reviewCount}
                     reviewPanel={reviewPanelV2}
+                    fileBrowserState={reviewV2State}
                     activeDiff={tree.activeDiff}
                     focusReviewDiff={focusReviewDiff}
                     reviewSnap={ui.reviewSnap}

+ 13 - 3
packages/app/src/pages/session/file-tabs.tsx

@@ -172,6 +172,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
 }
 
 export function FileTabContent(props: { tab: string }) {
+  return (
+    <Tabs.Content value={props.tab}>
+      <SessionFileView tab={props.tab} />
+    </Tabs.Content>
+  )
+}
+
+export function SessionFileView(props: { tab: string }) {
   const file = useFile()
   const comments = useComments()
   const language = useLanguage()
@@ -439,8 +447,8 @@ export function FileTabContent(props: { tab: string }) {
     </div>
   )
 
-  return (
-    <Tabs.Content value={props.tab} class="mt-3 relative h-full">
+  const content = () => (
+    <div class="mt-3 relative h-full min-h-0">
       <ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
         <Switch>
           <Match when={state()?.loaded}>{renderFile(contents())}</Match>
@@ -450,6 +458,8 @@ export function FileTabContent(props: { tab: string }) {
           <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
         </Switch>
       </ScrollView>
-    </Tabs.Content>
+    </div>
   )
+
+  return content()
 }

+ 46 - 0
packages/app/src/pages/session/helpers.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
 import { createMemo, createRoot } from "solid-js"
 import { createStore } from "solid-js/store"
 import {
+  SESSION_OPEN_FILE_TAB,
   createOpenReviewFile,
   createOpenSessionFileTab,
   createSessionTabs,
@@ -165,4 +166,49 @@ describe("createSessionTabs", () => {
       dispose()
     })
   })
+
+  test("exposes the Open File tab without treating it as a file tab", () => {
+    createRoot((dispose) => {
+      const [state] = createStore({
+        active: SESSION_OPEN_FILE_TAB as string | undefined,
+        all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
+      })
+      const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
+      const result = createSessionTabs({
+        tabs,
+        pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
+        normalizeTab: (tab) => tab,
+        fileBrowser: () => true,
+      })
+
+      expect(result.openFileOpen()).toBe(true)
+      expect(result.panelTabs()).toEqual(["file://src/a.ts", SESSION_OPEN_FILE_TAB])
+      expect(result.openedTabs()).toEqual(["file://src/a.ts"])
+      expect(result.activeTab()).toBe(SESSION_OPEN_FILE_TAB)
+      expect(result.activeFileTab()).toBeUndefined()
+      expect(result.closableTab()).toBe(SESSION_OPEN_FILE_TAB)
+      dispose()
+    })
+  })
+
+  test("hides the Open File placeholder when the file browser is unavailable", () => {
+    createRoot((dispose) => {
+      const [state] = createStore({
+        active: SESSION_OPEN_FILE_TAB as string | undefined,
+        all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
+      })
+      const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
+      const result = createSessionTabs({
+        tabs,
+        pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
+        normalizeTab: (tab) => tab,
+        fileBrowser: () => false,
+      })
+
+      expect(result.openFileOpen()).toBe(false)
+      expect(result.panelTabs()).toEqual(["file://src/a.ts"])
+      expect(result.activeTab()).toBe("file://src/a.ts")
+      dispose()
+    })
+  })
 })

+ 19 - 1
packages/app/src/pages/session/helpers.ts

@@ -2,6 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createStore } from "solid-js/store"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { same } from "@/utils/same"
+import { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
+
+export { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
 
 const emptyTabs: string[] = []
 
@@ -16,6 +19,7 @@ type TabsInput = {
   normalizeTab: (tab: string) => string
   review?: Accessor<boolean>
   hasReview?: Accessor<boolean>
+  fileBrowser?: Accessor<boolean>
 }
 
 export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
@@ -27,8 +31,14 @@ export function shouldShowFileTree(input: { visible: boolean; opened: boolean })
 export const createSessionTabs = (input: TabsInput) => {
   const review = input.review ?? (() => false)
   const hasReview = input.hasReview ?? (() => false)
+  const fileBrowser = input.fileBrowser ?? (() => false)
   const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
-  const openedTabs = createMemo(
+  const openFileOpen = createMemo(
+    () =>
+      fileBrowser() &&
+      (input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)),
+  )
+  const panelTabs = createMemo(
     () => {
       const seen = new Set<string>()
       return input
@@ -36,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
         .all()
         .flatMap((tab) => {
           if (tab === "context" || tab === "review") return []
+          if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
           const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
           if (seen.has(value)) return []
           seen.add(value)
@@ -45,9 +56,13 @@ export const createSessionTabs = (input: TabsInput) => {
     emptyTabs,
     { equals: same },
   )
+  const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, {
+    equals: same,
+  })
   const activeTab = createMemo(() => {
     const active = input.tabs().active()
     if (active === "context") return active
+    if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
     if (active === "review" && review()) return active
     if (active && input.pathFromTab(active)) return input.normalizeTab(active)
 
@@ -65,12 +80,15 @@ export const createSessionTabs = (input: TabsInput) => {
   const closableTab = createMemo(() => {
     const active = activeTab()
     if (active === "context") return active
+    if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
     if (!openedTabs().includes(active)) return
     return active
   })
 
   return {
     contextOpen,
+    openFileOpen,
+    panelTabs,
     openedTabs,
     activeTab,
     activeFileTab,

+ 97 - 6
packages/app/src/pages/session/session-side-panel.tsx

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
 import { createMediaQuery } from "@solid-primitives/media"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Icon } from "@opencode-ai/ui/icon"
 import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
@@ -23,10 +24,10 @@ import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useSettings } from "@/context/settings"
-import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import {
+  SESSION_OPEN_FILE_TAB,
   createOpenSessionFileTab,
   createSessionTabs,
   getTabReorderIndex,
@@ -35,6 +36,7 @@ import {
 } from "@/pages/session/helpers"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
 
 type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
 
@@ -51,6 +53,7 @@ export function SessionSidePanel(props: {
   reviewHasFocusableContent: () => boolean
   reviewCount: () => number
   reviewPanel: () => JSX.Element
+  fileBrowserState?: SessionFileBrowserState
   activeDiff?: string
   focusReviewDiff: (path: string) => void
   reviewSnap: boolean
@@ -59,7 +62,6 @@ export function SessionSidePanel(props: {
 }) {
   const layout = useLayout()
   const settings = useSettings()
-  const sync = useSync()
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
@@ -155,8 +157,10 @@ export function SessionSidePanel(props: {
     normalizeTab,
     review: reviewTab,
     hasReview: props.canReview,
+    fileBrowser: () => !!props.fileBrowserState,
   })
   const contextOpen = tabState.contextOpen
+  const panelTabs = tabState.panelTabs
   const openedTabs = tabState.openedTabs
   const activeTab = tabState.activeTab
   const activeFileTab = tabState.activeFileTab
@@ -180,6 +184,33 @@ export function SessionSidePanel(props: {
   const [store, setStore] = createStore({
     activeDraggable: undefined as string | undefined,
   })
+  let fileFilter: HTMLInputElement | undefined
+  const temporaryTab = tabs().preview
+  const previewTab = (value: string) => {
+    const next = normalizeTab(value)
+    tabs().previewTab(next)
+    const path = file.pathFromTab(next)
+    if (path) void file.load(path)
+    openReviewPanel()
+    queueMicrotask(() => tabs().setActive(next))
+  }
+  const openFileBrowser = () => {
+    previewTab(SESSION_OPEN_FILE_TAB)
+    queueMicrotask(() => fileFilter?.focus())
+  }
+  const activateTab = (value: string) => {
+    const next = normalizeTab(value)
+    const path = file.pathFromTab(next)
+    if (path) void file.load(path)
+    openReviewPanel()
+    tabs().setActive(next)
+  }
+  const browserTab = createMemo(() => {
+    if (!props.fileBrowserState) return undefined
+    if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
+    return activeFileTab()
+  })
+  const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
 
   const handleDragStart = (event: unknown) => {
     const id = getDraggableId(event)
@@ -265,7 +296,7 @@ export function SessionSidePanel(props: {
                 >
                   <DragDropSensors />
                   <ConstrainDragYAxis />
-                  <Tabs value={activeTab()} onChange={openTab}>
+                  <Tabs value={activeTab()} onChange={activateTab}>
                     <div class="sticky top-0 shrink-0 flex">
                       <Tabs.List
                         ref={(el: HTMLDivElement) => {
@@ -316,7 +347,48 @@ export function SessionSidePanel(props: {
                           </Tabs.Trigger>
                         </Show>
                         <SortableProvider ids={openedTabs()}>
-                          <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
+                          <For each={panelTabs()}>
+                            {(tab) => (
+                              <Show
+                                when={tab === SESSION_OPEN_FILE_TAB}
+                                fallback={
+                                  <SortableTab
+                                    tab={tab}
+                                    temporary={temporaryTab() === tab}
+                                    onTabClose={tabs().close}
+                                    onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
+                                  />
+                                }
+                              >
+                                <Tabs.Trigger
+                                  value={SESSION_OPEN_FILE_TAB}
+                                  closeButton={
+                                    <TooltipKeybind
+                                      title={language.t("common.closeTab")}
+                                      keybind={command.keybind("tab.close")}
+                                      placement="bottom"
+                                      gutter={10}
+                                    >
+                                      <IconButton
+                                        icon="close-small"
+                                        variant="ghost"
+                                        class="h-5 w-5"
+                                        onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
+                                        aria-label={language.t("common.closeTab")}
+                                      />
+                                    </TooltipKeybind>
+                                  }
+                                  hideCloseButton
+                                  onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
+                                >
+                                  <div class="flex items-center gap-1.5 italic">
+                                    <Icon name="open-file" size="small" />
+                                    <span>{language.t("command.file.open")}</span>
+                                  </div>
+                                </Tabs.Trigger>
+                              </Show>
+                            )}
+                          </For>
                         </SortableProvider>
                         <div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
                           <TooltipKeybind
@@ -330,6 +402,10 @@ export function SessionSidePanel(props: {
                               iconSize="large"
                               class="!rounded-md"
                               onClick={() => {
+                                if (props.fileBrowserState) {
+                                  openFileBrowser()
+                                  return
+                                }
                                 void import("@/components/dialog-select-file").then((x) => {
                                   dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
                                 })
@@ -380,7 +456,20 @@ export function SessionSidePanel(props: {
                       </Tabs.Content>
                     </Show>
 
-                    <Show when={activeFileTab()} keyed>
+                    <Show when={browserTab()}>
+                      <SessionFileBrowserTab
+                        tab={browserTab()!}
+                        placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
+                        active={file.pathFromTab(browserTab()!)}
+                        kinds={browserKinds()}
+                        state={props.fileBrowserState!}
+                        onSelect={(path) => previewTab(file.tab(path))}
+                        onSelectPermanent={(path) => openTab(file.tab(path))}
+                        filterRef={(element) => (fileFilter = element)}
+                      />
+                    </Show>
+
+                    <Show when={!props.fileBrowserState && activeFileTab()} keyed>
                       {(tab) => <FileTabContent tab={tab} />}
                     </Show>
                   </Tabs>
@@ -390,7 +479,9 @@ export function SessionSidePanel(props: {
                         const path = file.pathFromTab(tab)
                         return (
                           <div data-component="tabs-drag-preview">
-                            <Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
+                            <Show when={path}>
+                              {(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
+                            </Show>
                           </div>
                         )
                       }}

+ 2 - 0
packages/app/src/pages/session/use-session-commands.tsx

@@ -25,6 +25,7 @@ export type SessionCommandContext = {
   setActiveMessage: (message: UserMessage | undefined) => void
   focusInput: () => void
   review?: () => boolean
+  fileBrowser?: () => boolean
 }
 
 const withCategory = (category: string) => {
@@ -83,6 +84,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     normalizeTab,
     review: actions.review,
     hasReview,
+    fileBrowser: actions.fileBrowser,
   })
   const activeFileTab = tabState.activeFileTab
   const closableTab = tabState.closableTab

+ 193 - 0
packages/app/src/pages/session/v2/session-file-browser-tab.tsx

@@ -0,0 +1,193 @@
+import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
+import { createQuery } from "@tanstack/solid-query"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { Icon } from "@opencode-ai/ui/icon"
+import {
+  SessionFilePanelV2,
+  SessionFilePanelV2Empty,
+  SessionFilePanelV2Title,
+} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
+import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
+import FileTree, { type Kind } from "@/components/file-tree"
+import { useFile } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { displayName } from "@/pages/layout/helpers"
+import { useSessionLayout } from "@/pages/session/session-layout"
+import { SessionFileView } from "@/pages/session/file-tabs"
+import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
+import { pathKey } from "@/utils/path-key"
+
+const emptyFiles: string[] = []
+
+export type SessionFileBrowserState = {
+  sidebarOpened: () => boolean
+  sidebarWidth: () => number
+  resizeSidebar: (width: number) => void
+  toggleSidebar: () => void
+}
+
+export function SessionFileBrowserTab(props: {
+  tab: string
+  placeholder: boolean
+  active?: string
+  kinds: ReadonlyMap<string, Kind>
+  state: SessionFileBrowserState
+  onSelect: (path: string) => void
+  onSelectPermanent: (path: string) => void
+  filterRef?: (element: HTMLInputElement) => void
+}) {
+  const file = useFile()
+  const language = useLanguage()
+  const layout = useLayout()
+  const sdk = useSDK()
+  const { workspaceKey } = useSessionLayout()
+  const resultsID = `session-file-browser-results-${createUniqueId()}`
+  const [filter, setFilter] = createSignal("")
+  const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
+  const query = createMemo(() => filter().trim())
+  const search = createQuery(() => {
+    const value = query()
+    return {
+      queryKey: ["session-open-file", workspaceKey(), value] as const,
+      enabled: value.length > 0,
+      queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
+    }
+  })
+  const files = createMemo(() => {
+    if (!query() || search.isPending) return emptyFiles
+    return [...new Set(search.data ?? emptyFiles)]
+  })
+  const highlighted = createMemo(() => {
+    const values = files()
+    if (values.length === 0) return undefined
+    const explicit = explicitHighlight()
+    if (explicit && values.includes(explicit)) return explicit
+    return values[0]
+  })
+  const loading = createMemo(() => query().length > 0 && search.isPending)
+  const project = createMemo(() => {
+    const directory = pathKey(sdk().directory)
+    return layout.projects
+      .list()
+      .find(
+        (item) =>
+          pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+      )
+  })
+  const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory }))
+  const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}`
+
+  const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
+    if (event.key === "Escape" && query()) {
+      event.preventDefault()
+      setFilter("")
+      return
+    }
+    if (!query()) return
+    applyFileListKeyDown(event, files(), highlighted(), {
+      onHighlight: setExplicitHighlight,
+      onSelect: props.onSelectPermanent,
+    })
+  }
+
+  return (
+    <Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
+      <SessionFilePanelV2
+        toolbar
+        toolbarStart={
+          <>
+            <SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
+            <Show when={!props.state.sidebarOpened()}>
+              <SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
+            </Show>
+          </>
+        }
+        sidebar={
+          <SessionReviewV2Sidebar
+            open={props.state.sidebarOpened()}
+            title={<span class="truncate">{title()}</span>}
+            filter={filter()}
+            onFilterChange={setFilter}
+            onFilterKeyDown={onFilterKeyDown}
+            filterAutofocus={props.placeholder}
+            filterRef={props.filterRef}
+            filterControls={resultsID}
+            filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
+            filterExpanded={query().length > 0 && files().length > 0}
+            width={props.state.sidebarWidth()}
+            onWidthChange={props.state.resizeSidebar}
+          >
+            <Show
+              when={query()}
+              fallback={
+                <FileTree
+                  path=""
+                  class="pt-1"
+                  active={props.active}
+                  kinds={props.kinds}
+                  onFileClick={(node) => props.onSelect(node.path)}
+                  onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
+                />
+              }
+            >
+              <Show
+                when={!loading()}
+                fallback={
+                  <div role="status" class="px-2 py-2 text-12-regular text-text-weak">
+                    {language.t("common.loading")}
+                    {language.t("common.loading.ellipsis")}
+                  </div>
+                }
+              >
+                <Show
+                  when={files().length > 0}
+                  fallback={
+                    <div role="status" class="px-2 py-2 text-12-regular text-text-weak">
+                      {language.t("palette.empty")}
+                    </div>
+                  }
+                >
+                  <SessionFileListV2
+                    id={resultsID}
+                    role="listbox"
+                    optionID={optionID}
+                    files={files()}
+                    kinds={props.kinds}
+                    active={props.active}
+                    highlighted={highlighted()}
+                    onFileClick={(path) => {
+                      setExplicitHighlight(path)
+                      props.onSelect(path)
+                    }}
+                    onFileDoubleClick={props.onSelectPermanent}
+                  />
+                </Show>
+              </Show>
+            </Show>
+          </SessionReviewV2Sidebar>
+        }
+      >
+        <Show
+          when={!props.placeholder}
+          fallback={
+            <SessionFilePanelV2Empty>
+              <div class="flex flex-col items-center gap-3 text-center text-text-weak">
+                <Icon name="file-tree" size="large" />
+                <div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
+                <div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
+              </div>
+            </SessionFilePanelV2Empty>
+          }
+        >
+          <div class="min-h-0 flex-1">
+            <Show when={props.tab} keyed>
+              {(tab) => <SessionFileView tab={tab} />}
+            </Show>
+          </div>
+        </Show>
+      </SessionFilePanelV2>
+    </Tabs.Content>
+  )
+}

+ 10 - 0
packages/app/src/pages/session/v2/session-file-list-v2.tsx

@@ -43,7 +43,11 @@ export function SessionFileListV2(props: {
   active?: string
   highlighted?: string
   kinds?: ReadonlyMap<string, Kind>
+  id?: string
+  role?: "listbox"
+  optionID?: (path: string) => string
   onFileClick: (path: string) => void
+  onFileDoubleClick?: (path: string) => void
 }) {
   const active = () => normalizePath(props.active ?? "")
   const highlighted = () => normalizePath(props.highlighted ?? "")
@@ -88,6 +92,8 @@ export function SessionFileListV2(props: {
   return (
     <div
       ref={setRoot}
+      id={props.id}
+      role={props.role}
       data-component="file-tree-v2"
       data-total-rows={props.files.length}
       style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
@@ -116,6 +122,9 @@ export function SessionFileListV2(props: {
                 >
                   <button
                     type="button"
+                    id={props.optionID?.(path)}
+                    role={props.role ? "option" : undefined}
+                    aria-selected={props.role ? selected() : undefined}
                     data-slot="file-tree-v2-row"
                     data-path={path}
                     data-selected={selected() ? "" : undefined}
@@ -124,6 +133,7 @@ export function SessionFileListV2(props: {
                     onFocus={() => setFocused(path)}
                     onBlur={() => setFocused(undefined)}
                     onClick={() => props.onFileClick(path)}
+                    onDblClick={() => props.onFileDoubleClick?.(path)}
                   >
                     <span class="filetree-iconpair size-4">
                       <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />

+ 43 - 0
packages/session-ui/src/v2/components/session-file-panel-v2.tsx

@@ -0,0 +1,43 @@
+import { Show, type JSX, type ParentProps } from "solid-js"
+import "./session-review-v2.css"
+
+export function SessionFilePanelV2(props: {
+  sidebar?: JSX.Element
+  toolbar: boolean
+  toolbarStart?: JSX.Element
+  toolbarEnd?: JSX.Element
+  children?: JSX.Element
+}) {
+  return (
+    <div data-component="session-review-v2">
+      <div data-slot="session-review-v2-body">
+        {props.sidebar}
+        <div data-slot="session-review-v2-preview">
+          <Show when={props.toolbar}>
+            <div data-slot="session-review-v2-toolbar">
+              <div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
+                {props.toolbarStart}
+              </div>
+              <Show when={props.toolbarEnd}>
+                {(toolbar) => (
+                  <div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
+                    {toolbar()}
+                  </div>
+                )}
+              </Show>
+            </div>
+          </Show>
+          {props.children}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function SessionFilePanelV2Title(props: ParentProps) {
+  return <div data-slot="session-review-v2-toolbar-title">{props.children}</div>
+}
+
+export function SessionFilePanelV2Empty(props: ParentProps) {
+  return <div data-slot="session-review-v2-empty">{props.children}</div>
+}

+ 129 - 122
packages/session-ui/src/v2/components/session-review-v2.tsx

@@ -12,7 +12,7 @@ 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"
+import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2"
 
 export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
 export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200
@@ -45,6 +45,11 @@ export type SessionReviewV2SidebarProps = {
   filter: string
   onFilterChange: (value: string) => void
   onFilterKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>
+  filterAutofocus?: boolean
+  filterRef?: (element: HTMLInputElement) => void
+  filterControls?: string
+  filterActiveDescendant?: string
+  filterExpanded?: boolean
   width?: number
   onWidthChange?: (width: number) => void
   minWidth?: number
@@ -86,6 +91,13 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
             value={props.filter}
             onInput={(event) => props.onFilterChange(event.currentTarget.value)}
             onKeyDown={props.onFilterKeyDown}
+            autofocus={props.filterAutofocus}
+            ref={props.filterRef}
+            role={props.filterControls ? "combobox" : undefined}
+            aria-autocomplete={props.filterControls ? "list" : undefined}
+            aria-controls={props.filterControls}
+            aria-activedescendant={props.filterActiveDescendant}
+            aria-expanded={props.filterControls ? props.filterExpanded : undefined}
             showClearButton={props.filter.length > 0}
             clearLabel={i18n.t("ui.list.clearFilter")}
             onClearClick={() => props.onFilterChange("")}
@@ -185,130 +197,125 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
     cycle(event.key === "<" ? prev() : next())
   })
 
-  return (
-    <div data-component="session-review-v2">
-      <div data-slot="session-review-v2-body">
-        {props.sidebar}
-
-        <div data-slot="session-review-v2-preview">
-          <Show when={props.hasDiffs} fallback={props.empty}>
-            <div data-slot="session-review-v2-toolbar">
-              <div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
-                {props.sidebarToggle}
-                <Show when={showCollapsedMeta()}>
-                  <div data-slot="session-review-v2-toolbar-collapsed-meta">
-                    <Show when={title()}>
-                      <div data-slot="session-review-v2-toolbar-title">{title()}</div>
-                    </Show>
-                    {stats()}
-                    <Show when={canCycle()}>
-                      <span data-slot="session-review-v2-file-position">
-                        {fileIndex() + 1}/{props.files.length}
-                      </span>
-                    </Show>
-                  </div>
-                </Show>
-                <div data-slot="session-review-v2-toolbar-group">
-                  <TooltipV2
-                    openDelay={2000}
-                    value={
-                      <>
-                        {i18n.t("ui.sessionReviewV2.previousFile")}
-                        <KeybindV2 keys={["<"]} variant="neutral" />
-                      </>
-                    }
-                  >
-                    <IconButton
-                      icon="arrow-left"
-                      variant="ghost"
-                      size="small"
-                      class="session-review-v2-file-nav-button"
-                      disabled={!canCycle()}
-                      onClick={() => {
-                        const file = prev()
-                        if (!file) return
-                        props.onSelectFile(file)
-                      }}
-                      aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
-                    />
-                  </TooltipV2>
-                  <TooltipV2
-                    openDelay={2000}
-                    value={
-                      <>
-                        {i18n.t("ui.sessionReviewV2.nextFile")}
-                        <KeybindV2 keys={[">"]} variant="neutral" />
-                      </>
-                    }
-                  >
-                    <IconButton
-                      icon="arrow-right"
-                      variant="ghost"
-                      size="small"
-                      class="session-review-v2-file-nav-button"
-                      disabled={!canCycle()}
-                      onClick={() => {
-                        const file = next()
-                        if (!file) return
-                        props.onSelectFile(file)
-                      }}
-                      aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
-                    />
-                  </TooltipV2>
-                </div>
-              </div>
-              <div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
-                <SegmentedControlV2
-                  value={props.expandMode}
-                  onChange={(value) => {
-                    if (value !== "expand" && value !== "collapse") return
-                    props.onExpandModeChange(value)
-                  }}
-                  class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
-                  aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
-                >
-                  <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
-                    <SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
-                      <Icon name="expand" />
-                    </SegmentedControlItemV2>
-                  </TooltipV2>
-                  <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
-                    <SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
-                      <Icon name="collapse" />
-                    </SegmentedControlItemV2>
-                  </TooltipV2>
-                </SegmentedControlV2>
-                <Show when={props.onDiffStyleChange}>
-                  <SegmentedControlV2
-                    value={props.diffStyle}
-                    onChange={(value) => {
-                      if (value !== "unified" && value !== "split") return
-                      props.onDiffStyleChange?.(value)
-                    }}
-                    class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
-                    aria-label={i18n.t("ui.sessionReviewV2.diffView")}
-                  >
-                    <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
-                      <SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
-                        <Icon name="unified" />
-                      </SegmentedControlItemV2>
-                    </TooltipV2>
-                    <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
-                      <SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
-                        <Icon name="split" />
-                      </SegmentedControlItemV2>
-                    </TooltipV2>
-                  </SegmentedControlV2>
-                </Show>
-              </div>
-            </div>
-            <Show when={props.activeFile} fallback={<div data-slot="session-review-v2-empty">{props.empty}</div>}>
-              {props.preview}
-            </Show>
+  const toolbarStart = () => (
+    <>
+      {props.sidebarToggle}
+      <Show when={showCollapsedMeta()}>
+        <div data-slot="session-review-v2-toolbar-collapsed-meta">
+          <Show when={title()}>
+            <div data-slot="session-review-v2-toolbar-title">{title()}</div>
+          </Show>
+          {stats()}
+          <Show when={canCycle()}>
+            <span data-slot="session-review-v2-file-position">
+              {fileIndex() + 1}/{props.files.length}
+            </span>
           </Show>
         </div>
+      </Show>
+      <div class="flex items-center">
+        <TooltipV2
+          openDelay={2000}
+          value={
+            <>
+              {i18n.t("ui.sessionReviewV2.previousFile")}
+              <KeybindV2 keys={["<"]} variant="neutral" />
+            </>
+          }
+        >
+          <IconButton
+            icon="arrow-left"
+            variant="ghost"
+            size="small"
+            class="session-review-v2-file-nav-button"
+            disabled={!canCycle()}
+            onClick={() => cycle(prev())}
+            aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
+          />
+        </TooltipV2>
+        <TooltipV2
+          openDelay={2000}
+          value={
+            <>
+              {i18n.t("ui.sessionReviewV2.nextFile")}
+              <KeybindV2 keys={[">"]} variant="neutral" />
+            </>
+          }
+        >
+          <IconButton
+            icon="arrow-right"
+            variant="ghost"
+            size="small"
+            class="session-review-v2-file-nav-button"
+            disabled={!canCycle()}
+            onClick={() => cycle(next())}
+            aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
+          />
+        </TooltipV2>
       </div>
-    </div>
+    </>
+  )
+
+  const toolbarEnd = () => (
+    <>
+      <SegmentedControlV2
+        value={props.expandMode}
+        onChange={(value) => {
+          if (value !== "expand" && value !== "collapse") return
+          props.onExpandModeChange(value)
+        }}
+        class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
+        aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
+      >
+        <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
+          <SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
+            <Icon name="expand" />
+          </SegmentedControlItemV2>
+        </TooltipV2>
+        <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
+          <SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
+            <Icon name="collapse" />
+          </SegmentedControlItemV2>
+        </TooltipV2>
+      </SegmentedControlV2>
+      <Show when={props.onDiffStyleChange}>
+        <SegmentedControlV2
+          value={props.diffStyle}
+          onChange={(value) => {
+            if (value !== "unified" && value !== "split") return
+            props.onDiffStyleChange?.(value)
+          }}
+          class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
+          aria-label={i18n.t("ui.sessionReviewV2.diffView")}
+        >
+          <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
+            <SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
+              <Icon name="unified" />
+            </SegmentedControlItemV2>
+          </TooltipV2>
+          <TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
+            <SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
+              <Icon name="split" />
+            </SegmentedControlItemV2>
+          </TooltipV2>
+        </SegmentedControlV2>
+      </Show>
+    </>
+  )
+
+  return (
+    <SessionFilePanelV2
+      sidebar={props.sidebar}
+      toolbar={props.hasDiffs}
+      toolbarStart={toolbarStart()}
+      toolbarEnd={toolbarEnd()}
+    >
+      <Show when={props.hasDiffs} fallback={props.empty}>
+        <Show when={props.activeFile} fallback={<SessionFilePanelV2Empty>{props.empty}</SessionFilePanelV2Empty>}>
+          {props.preview}
+        </Show>
+      </Show>
+    </SessionFilePanelV2>
   )
 }