Kaynağa Gözat

Merge branch 'dev' of https://github.com/anomalyco/opencode into interface-toggle

usrnk1 3 hafta önce
ebeveyn
işleme
8ce5038bd8
35 değiştirilmiş dosya ile 2371 ekleme ve 452 silme
  1. 150 0
      packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts
  2. 9 1
      packages/app/e2e/regression/review-open-file.spec.ts
  3. 2 2
      packages/app/e2e/regression/review-tab-switch.spec.ts
  4. 1 1
      packages/app/e2e/regression/review-terminal-stacked.spec.ts
  5. 156 0
      packages/app/src/components/dialog-edit-project-v2.tsx
  6. 38 135
      packages/app/src/components/dialog-edit-project.tsx
  7. 119 0
      packages/app/src/components/edit-project.ts
  8. 36 8
      packages/app/src/components/file-tree-v2-model.test.ts
  9. 30 0
      packages/app/src/components/file-tree-v2-model.ts
  10. 60 29
      packages/app/src/components/file-tree-v2.tsx
  11. 1 0
      packages/app/src/components/session/index.ts
  12. 98 0
      packages/app/src/components/session/open-in-app-v2.tsx
  13. 229 0
      packages/app/src/components/session/open-in-app.tsx
  14. 66 0
      packages/app/src/components/session/session-sortable-tab-v2.tsx
  15. 12 4
      packages/app/src/context/layout.tsx
  16. 1 1
      packages/app/src/i18n/en.ts
  17. 24 5
      packages/app/src/pages/home-session-open.test.ts
  18. 3 0
      packages/app/src/pages/home-session-open.ts
  19. 21 4
      packages/app/src/pages/home.tsx
  20. 8 0
      packages/app/src/pages/session.tsx
  21. 329 1
      packages/app/src/pages/session/file-tabs.tsx
  22. 409 157
      packages/app/src/pages/session/session-side-panel.tsx
  23. 3 5
      packages/app/src/pages/session/v2/review-panel-v2.tsx
  24. 74 89
      packages/app/src/pages/session/v2/session-file-browser-tab.tsx
  25. 8 0
      packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx
  26. 6 1
      packages/session-ui/src/v2/components/session-review-v2.css
  27. 2 3
      packages/session-ui/src/v2/components/session-review-v2.tsx
  28. 101 0
      packages/ui/src/components/tabs.css
  29. 18 1
      packages/ui/src/v2/components/file-tree-v2.css
  30. 4 0
      packages/ui/src/v2/components/icon.tsx
  31. 53 0
      packages/ui/src/v2/components/line-comment-v2.css
  32. 151 2
      packages/ui/src/v2/components/line-comment-v2.tsx
  33. 2 3
      packages/ui/src/v2/components/project-avatar-v2.tsx
  34. 99 0
      packages/ui/src/v2/components/split-button-v2.css
  35. 48 0
      packages/ui/src/v2/components/split-button-v2.tsx

+ 150 - 0
packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts

@@ -0,0 +1,150 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/FileBrowserSidebar"
+const projectID = "proj_file_browser_sidebar"
+const sessionID = "ses_file_browser_sidebar"
+const title = "File browser sidebar"
+const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
+// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
+const PROBE = "original"
+
+test.use({ viewport: { width: 1440, height: 900 } })
+
+// The file-browser sidebar must stay mounted across preview/pinned file-tab
+// switches. Remounting resets scroll and filter state.
+test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
+  await setup(page)
+
+  await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
+  await expectSessionTitle(page, title)
+
+  const panel = page.locator("#review-panel")
+  await panel.getByRole("button", { name: "Open file" }).click()
+  await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
+
+  const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
+  await expect(sidebar).toBeVisible()
+  await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
+
+  await panel.getByRole("button", { name: "file-00.ts" }).click()
+  await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
+  await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
+
+  const viewport = panel.locator('[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)
+  const scrolled = await viewport.evaluate((element) => element.scrollTop)
+  expect(scrolled).toBeGreaterThan(0)
+  await writeProbe(page)
+
+  await panel.getByRole("button", { name: "file-79.ts" }).click()
+  await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
+  await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
+  expect(await readProbe(page)).toBe(PROBE)
+  await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
+
+  await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
+  await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
+  await panel.getByRole("button", { name: "file-79.ts" }).click()
+  await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
+  await panel.getByRole("tab", { name: "file-78.ts" }).click()
+  await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
+  expect(await readProbe(page)).toBe(PROBE)
+  await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
+})
+
+type Probed = HTMLElement & { __e2eProbe?: string }
+
+async function writeProbe(page: Page) {
+  await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
+    ;(el as Probed).__e2eProbe = probe
+  }, PROBE)
+}
+
+async function readProbe(page: Page) {
+  return page
+    .locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
+    .evaluate((el) => (el as Probed).__e2eProbe)
+}
+
+async function setup(page: Page) {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "file-browser-sidebar",
+      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: [],
+    fileList: (path) => {
+      if (path) return []
+      return files.map((name) => ({
+        name,
+        path: name,
+        absolute: `${directory}/${name}`,
+        type: "file" as const,
+        ignored: false,
+      }))
+    },
+    fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
+    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.global.dat:review-panel-v2",
+        JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
+      )
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
+      )
+    },
+    { directory, server, sessionID },
+  )
+}

+ 9 - 1
packages/app/e2e/regression/review-open-file.spec.ts

@@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => {
 
   const panel = page.locator("#review-panel")
   const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
+  const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
   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 expect(sidebarToggle).toBeDisabled()
   await expect(sidebar).toBeVisible()
   await contextButton.click()
   await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
-  await expect(sidebar).toHaveCount(0)
+  await expect(sidebar).toBeHidden()
   await panel.getByRole("button", { name: "Open file" }).click()
   const filter = panel.getByRole("combobox", { name: "Filter files" })
   await expect(filter).toBeFocused()
@@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => {
 
   await panel.getByRole("button", { name: "README.md" }).click()
   await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
+  await expect(sidebarToggle).toBeEnabled()
   await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
   await expect(sidebar).toHaveCount(0)
 
@@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
   await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
   await filter.press("Enter")
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
+  await expect(sidebarToggle).toBeEnabled()
   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 expect(sidebarToggle).toBeDisabled()
+  await panel.getByRole("tab", { name: /Review/ }).click()
+  await expect(sidebarToggle).toBeEnabled()
+  await panel.getByRole("tab", { name: "Open file" }).click()
   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", "")

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

@@ -28,8 +28,8 @@ 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/ })
+  const reviewTab = page.locator("#session-side-panel-review-tab")
+  const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
   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"]')

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

@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
   await expectTree(page, 8, "git-0.ts")
 
   await selectMode(page, "Git changes", "Branch changes")
-  await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
+  await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
   await page.keyboard.press("Control+Backquote")
   await expect(page.locator("#terminal-panel")).toBeVisible()
   await expectTree(page, 2_773, "action.yml")

+ 156 - 0
packages/app/src/components/dialog-edit-project-v2.tsx

@@ -0,0 +1,156 @@
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
+import { Field } from "@opencode-ai/ui/v2/field-v2"
+import { Icon } from "@opencode-ai/ui/v2/icon"
+import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
+import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
+import { For, Show } from "solid-js"
+import { useLanguage } from "@/context/language"
+import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { getProjectAvatarSource } from "@/pages/layout/helpers"
+import { createEditProjectModel } from "./edit-project"
+
+export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
+  const language = useLanguage()
+  const model = createEditProjectModel(props)
+
+  return (
+    <Dialog fit>
+      <form onSubmit={model.submit} class="contents">
+        <DialogHeader>
+          <DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
+        </DialogHeader>
+        <DividerV2 />
+        <DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
+          <Field>
+            <Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
+            <TextInputV2
+              autofocus
+              appearance="large"
+              class="!w-full"
+              value={model.store.name}
+              placeholder={model.folderName()}
+              onInput={(event) => model.setStore("name", event.currentTarget.value)}
+            />
+          </Field>
+
+          <div class="flex w-full flex-col gap-2">
+            <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
+              {language.t("dialog.project.edit.icon")}
+            </div>
+            <div class="flex items-center gap-3">
+              <button
+                type="button"
+                aria-label={language.t("dialog.project.edit.icon.alt")}
+                class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
+                classList={{
+                  "bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
+                }}
+                onMouseEnter={() => model.setStore("iconHover", true)}
+                onMouseLeave={() => model.setStore("iconHover", false)}
+                onDrop={model.drop}
+                onDragOver={model.dragOver}
+                onDragLeave={model.dragLeave}
+                onClick={model.iconClick}
+              >
+                <ProjectAvatar
+                  fallback={model.store.name || model.defaultName()}
+                  src={getProjectAvatarSource(props.project.id, {
+                    color: model.store.color,
+                    url: props.project.icon?.url,
+                    override: model.store.iconOverride,
+                  })}
+                  variant={getProjectAvatarVariant(model.store.color)}
+                  class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
+                />
+                <span
+                  class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
+                  classList={{
+                    "opacity-100": model.store.iconHover,
+                    "opacity-0": !model.store.iconHover,
+                  }}
+                >
+                  <Icon name={model.store.iconOverride ? "close" : "outline-share"} />
+                </span>
+              </button>
+              <input
+                ref={(element) => {
+                  model.setIconInput(element)
+                }}
+                type="file"
+                accept="image/*"
+                class="hidden"
+                onChange={model.inputChange}
+              />
+              <div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                <span>{language.t("dialog.project.edit.icon.hint")}</span>
+                <span>{language.t("dialog.project.edit.icon.recommended")}</span>
+              </div>
+            </div>
+          </div>
+
+          <Show when={!model.store.iconOverride}>
+            <div class="flex w-full flex-col gap-2">
+              <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
+                {language.t("dialog.project.edit.color")}
+              </div>
+              <div class="-ml-1 flex gap-1.5">
+                <For each={PROJECT_AVATAR_VARIANTS}>
+                  {(color) => (
+                    <button
+                      type="button"
+                      aria-label={language.t("dialog.project.edit.color.select", { color })}
+                      aria-pressed={getProjectAvatarVariant(model.store.color) === color}
+                      class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
+                      classList={{
+                        "bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
+                          getProjectAvatarVariant(model.store.color) === color,
+                      }}
+                      onClick={() => {
+                        if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
+                        model.setStore(
+                          "color",
+                          getProjectAvatarVariant(model.store.color) === color ? undefined : color,
+                        )
+                      }}
+                    >
+                      <ProjectAvatar
+                        fallback={model.store.name || model.defaultName()}
+                        variant={getProjectAvatarVariant(color)}
+                        class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
+                      />
+                    </button>
+                  )}
+                </For>
+              </div>
+            </div>
+          </Show>
+
+          <Field>
+            <Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
+            <Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
+            <TextareaV2
+              class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
+              rows={3}
+              value={model.store.startup}
+              placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
+              spellcheck={false}
+              onInput={(event) => model.setStore("startup", event.currentTarget.value)}
+            />
+          </Field>
+        </DialogBody>
+        <DialogFooter>
+          <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
+            {language.t("common.cancel")}
+          </ButtonV2>
+          <ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
+            {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
+          </ButtonV2>
+        </DialogFooter>
+      </form>
+    </Dialog>
+  )
+}

+ 38 - 135
packages/app/src/components/dialog-edit-project.tsx

@@ -1,123 +1,32 @@
 import { Button } from "@opencode-ai/ui/button"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { TextField } from "@opencode-ai/ui/text-field"
-import { useMutation } from "@tanstack/solid-query"
 import { Icon } from "@opencode-ai/ui/icon"
-import { createMemo, For, Show } from "solid-js"
-import { createStore } from "solid-js/store"
+import { For, Show } from "solid-js"
 import { type LocalProject, getAvatarColors } from "@/context/layout"
-import { getFilename } from "@opencode-ai/core/util/path"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { useLanguage } from "@/context/language"
 import { getProjectAvatarSource } from "@/pages/layout/helpers"
 import { ServerConnection } from "@/context/server"
-import { useGlobal } from "@/context/global"
+import { createEditProjectModel } from "./edit-project"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 
 export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
-  const dialog = useDialog()
-  const global = useGlobal()
   const language = useLanguage()
-  const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
-  const serverSDK = () => serverCtx().sdk
-  const serverSync = () => serverCtx().sync
-
-  const folderName = createMemo(() => getFilename(props.project.worktree))
-  const defaultName = createMemo(() => props.project.name || folderName())
-
-  const [store, setStore] = createStore({
-    name: defaultName(),
-    color: props.project.icon?.color,
-    iconOverride: props.project.icon?.override,
-    startup: props.project.commands?.start ?? "",
-    dragOver: false,
-    iconHover: false,
-  })
-
-  let iconInput: HTMLInputElement | undefined
-
-  function handleFileSelect(file: File) {
-    if (!file.type.startsWith("image/")) return
-    const reader = new FileReader()
-    reader.onload = (e) => {
-      setStore("iconOverride", e.target?.result as string)
-      setStore("iconHover", false)
-    }
-    reader.readAsDataURL(file)
-  }
-
-  function handleDrop(e: DragEvent) {
-    e.preventDefault()
-    setStore("dragOver", false)
-    const file = e.dataTransfer?.files[0]
-    if (file) handleFileSelect(file)
-  }
-
-  function handleDragOver(e: DragEvent) {
-    e.preventDefault()
-    setStore("dragOver", true)
-  }
-
-  function handleDragLeave() {
-    setStore("dragOver", false)
-  }
-
-  function handleInputChange(e: Event) {
-    const input = e.target as HTMLInputElement
-    const file = input.files?.[0]
-    if (file) handleFileSelect(file)
-  }
-
-  function clearIcon() {
-    setStore("iconOverride", "")
-  }
-
-  const saveMutation = useMutation(() => ({
-    mutationFn: async () => {
-      const name = store.name.trim() === folderName() ? "" : store.name.trim()
-      const start = store.startup.trim()
-
-      if (props.project.id && props.project.id !== "global") {
-        await serverSDK().client.project.update({
-          projectID: props.project.id,
-          directory: props.project.worktree,
-          name,
-          icon: { color: store.color || "", override: store.iconOverride || "" },
-          commands: { start },
-        })
-        serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
-        dialog.close()
-        return
-      }
-
-      serverSync().project.meta(props.project.worktree, {
-        name,
-        icon: { color: store.color || undefined, override: store.iconOverride || undefined },
-        commands: { start: start || undefined },
-      })
-      dialog.close()
-    },
-  }))
-
-  function handleSubmit(e: SubmitEvent) {
-    e.preventDefault()
-    if (saveMutation.isPending) return
-    saveMutation.mutate()
-  }
+  const model = createEditProjectModel(props)
 
   return (
     <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
-      <form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
+      <form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
         <div class="flex flex-col gap-4">
           <TextField
             autofocus
             type="text"
             label={language.t("dialog.project.edit.name")}
-            placeholder={folderName()}
-            value={store.name}
-            onChange={(v) => setStore("name", v)}
+            placeholder={model.folderName()}
+            value={model.store.name}
+            onChange={(v) => model.setStore("name", v)}
           />
 
           <div class="flex flex-col gap-2">
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             <div class="flex gap-3 items-start">
               <div
                 class="relative"
-                onMouseEnter={() => setStore("iconHover", true)}
-                onMouseLeave={() => setStore("iconHover", false)}
+                onMouseEnter={() => model.setStore("iconHover", true)}
+                onMouseLeave={() => model.setStore("iconHover", false)}
               >
                 <div
                   class="relative size-16 rounded-md transition-colors cursor-pointer"
                   classList={{
-                    "border-text-interactive-base bg-surface-info-base/20": store.dragOver,
-                    "border-border-base hover:border-border-strong": !store.dragOver,
-                    "overflow-hidden": !!store.iconOverride,
-                  }}
-                  onDrop={handleDrop}
-                  onDragOver={handleDragOver}
-                  onDragLeave={handleDragLeave}
-                  onClick={() => {
-                    if (store.iconOverride && store.iconHover) {
-                      clearIcon()
-                    } else {
-                      iconInput?.click()
-                    }
+                    "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
+                    "border-border-base hover:border-border-strong": !model.store.dragOver,
+                    "overflow-hidden": !!model.store.iconOverride,
                   }}
+                  onDrop={model.drop}
+                  onDragOver={model.dragOver}
+                  onDragLeave={model.dragLeave}
+                  onClick={model.iconClick}
                 >
                   <Show
                     when={getProjectAvatarSource(props.project.id, {
-                      color: store.color,
+                      color: model.store.color,
                       url: props.project.icon?.url,
-                      override: store.iconOverride,
+                      override: model.store.iconOverride,
                     })}
                     fallback={
                       <div class="size-full flex items-center justify-center">
                         <Avatar
-                          fallback={store.name || defaultName()}
-                          {...getAvatarColors(store.color)}
+                          fallback={model.store.name || model.defaultName()}
+                          {...getAvatarColors(model.store.color)}
                           class="size-full text-[32px]"
                         />
                       </div>
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                 <div
                   class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
                   classList={{
-                    "opacity-100": store.iconHover && !store.iconOverride,
-                    "opacity-0": !(store.iconHover && !store.iconOverride),
+                    "opacity-100": model.store.iconHover && !model.store.iconOverride,
+                    "opacity-0": !(model.store.iconHover && !model.store.iconOverride),
                   }}
                 >
                   <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                 <div
                   class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
                   classList={{
-                    "opacity-100": store.iconHover && !!store.iconOverride,
-                    "opacity-0": !(store.iconHover && !!store.iconOverride),
+                    "opacity-100": model.store.iconHover && !!model.store.iconOverride,
+                    "opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
                   }}
                 >
                   <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
               <input
                 id="icon-upload"
                 ref={(el) => {
-                  iconInput = el
+                  model.setIconInput(el)
                 }}
                 type="file"
                 accept="image/*"
                 class="hidden"
-                onChange={handleInputChange}
+                onChange={model.inputChange}
               />
               <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
                 <span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             </div>
           </div>
 
-          <Show when={!store.iconOverride}>
+          <Show when={!model.store.iconOverride}>
             <div class="flex flex-col gap-2">
               <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
               <div class="flex gap-1.5">
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                     <button
                       type="button"
                       aria-label={language.t("dialog.project.edit.color.select", { color })}
-                      aria-pressed={store.color === color}
+                      aria-pressed={model.store.color === color}
                       classList={{
                         "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
                         "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
-                          store.color === color,
+                          model.store.color === color,
                         "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
-                          store.color !== color,
+                          model.store.color !== color,
                       }}
                       onClick={() => {
-                        if (store.color === color && !props.project.icon?.url) return
-                        setStore("color", store.color === color ? undefined : color)
+                        if (model.store.color === color && !props.project.icon?.url) return
+                        model.setStore("color", model.store.color === color ? undefined : color)
                       }}
                     >
                       <Avatar
-                        fallback={store.name || defaultName()}
+                        fallback={model.store.name || model.defaultName()}
                         {...getAvatarColors(color)}
                         class="size-full rounded"
                       />
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             label={language.t("dialog.project.edit.worktree.startup")}
             description={language.t("dialog.project.edit.worktree.startup.description")}
             placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
-            value={store.startup}
-            onChange={(v) => setStore("startup", v)}
+            value={model.store.startup}
+            onChange={(v) => model.setStore("startup", v)}
             spellcheck={false}
             class="max-h-14 w-full overflow-y-auto font-mono text-xs"
           />
         </div>
 
         <div class="flex justify-end gap-2">
-          <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
+          <Button type="button" variant="ghost" size="large" onClick={model.close}>
             {language.t("common.cancel")}
           </Button>
-          <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
-            {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
+          <Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
+            {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
           </Button>
         </div>
       </form>

+ 119 - 0
packages/app/src/components/edit-project.ts

@@ -0,0 +1,119 @@
+import { getFilename } from "@opencode-ai/core/util/path"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useMutation } from "@tanstack/solid-query"
+import { createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useGlobal } from "@/context/global"
+import { type LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+
+export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
+  const dialog = useDialog()
+  const global = useGlobal()
+  const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
+  const folderName = createMemo(() => getFilename(props.project.worktree))
+  const defaultName = createMemo(() => props.project.name || folderName())
+  const [store, setStore] = createStore({
+    name: defaultName(),
+    color: props.project.icon?.color,
+    iconOverride: props.project.icon?.override,
+    startup: props.project.commands?.start ?? "",
+    dragOver: false,
+    iconHover: false,
+  })
+  let iconInput: HTMLInputElement | undefined
+
+  function selectFile(file: File) {
+    if (!file.type.startsWith("image/")) return
+    const reader = new FileReader()
+    reader.onload = (event) => {
+      const result = event.target?.result
+      if (typeof result !== "string") return
+      setStore("iconOverride", result)
+      setStore("iconHover", false)
+    }
+    reader.readAsDataURL(file)
+  }
+
+  function drop(event: DragEvent) {
+    event.preventDefault()
+    setStore("dragOver", false)
+    const file = event.dataTransfer?.files[0]
+    if (file) selectFile(file)
+  }
+
+  function dragOver(event: DragEvent) {
+    event.preventDefault()
+    setStore("dragOver", true)
+  }
+
+  function dragLeave() {
+    setStore("dragOver", false)
+  }
+
+  function inputChange(event: Event) {
+    const file = (event.currentTarget as HTMLInputElement).files?.[0]
+    if (file) selectFile(file)
+  }
+
+  function iconClick() {
+    if (store.iconOverride && store.iconHover) {
+      setStore("iconOverride", "")
+      return
+    }
+    iconInput?.click()
+  }
+
+  const save = useMutation(() => ({
+    mutationFn: async () => {
+      const name = store.name.trim() === folderName() ? "" : store.name.trim()
+      const start = store.startup.trim()
+
+      if (props.project.id && props.project.id !== "global") {
+        await serverCtx().sdk.client.project.update({
+          projectID: props.project.id,
+          directory: props.project.worktree,
+          name,
+          icon: { color: store.color || "", override: store.iconOverride || "" },
+          commands: { start },
+        })
+        serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
+        dialog.close()
+        return
+      }
+
+      serverCtx().sync.project.meta(props.project.worktree, {
+        name,
+        icon: { color: store.color || undefined, override: store.iconOverride || undefined },
+        commands: { start: start || undefined },
+      })
+      dialog.close()
+    },
+  }))
+
+  function submit(event: SubmitEvent) {
+    event.preventDefault()
+    if (save.isPending) return
+    save.mutate()
+  }
+
+  return {
+    store,
+    setStore,
+    folderName,
+    defaultName,
+    save,
+    submit,
+    drop,
+    dragOver,
+    dragLeave,
+    inputChange,
+    iconClick,
+    close() {
+      dialog.close()
+    },
+    setIconInput(input: HTMLInputElement) {
+      iconInput = input
+    },
+  }
+}

+ 36 - 8
packages/app/src/components/file-tree-v2-model.test.ts

@@ -1,8 +1,9 @@
 import { describe, expect, test } from "bun:test"
-import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
+import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
+import type { FileNode } from "@opencode-ai/sdk/v2"
 
-describe("file tree v2 model", () => {
-  test("builds sorted depth-first rows", () => {
+describe("buildFileTreeV2Model", () => {
+  test("builds a sorted tree and flattens expanded directories", () => {
     const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
 
     expect(model.total).toBe(8)
@@ -18,7 +19,7 @@ describe("file tree v2 model", () => {
     ])
   })
 
-  test("omits descendants of collapsed directories", () => {
+  test("skips children 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([
@@ -28,19 +29,46 @@ describe("file tree v2 model", () => {
     ])
   })
 
-  test("normalizes separators and duplicate paths", () => {
+  test("normalizes duplicate and messy 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`
+  test("handles deeply nested paths", () => {
+    const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
     const model = buildFileTreeV2Model([file])
 
     expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
   })
 })
+
+describe("flattenLiveFileTreeV2", () => {
+  test("flattens live children using original paths for nested lookups", () => {
+    const nodes: Record<string, FileNode[]> = {
+      "": [
+        { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
+        { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
+      ],
+      src: [
+        { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
+        { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
+      ],
+      "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
+    }
+
+    expect(
+      flattenLiveFileTreeV2(
+        (path) => nodes[path] ?? [],
+        (path) => path === "src",
+      ).map((row) => [row.node.path, row.node.originalPath, row.level]),
+    ).toEqual([
+      ["src", "src", 0],
+      ["src/a.ts", "src/a.ts", 1],
+      ["src/lib", "src/lib", 1],
+      ["README.md", "README.md", 0],
+    ])
+  })
+})

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

@@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
 
   return rows
 }
+
+export function flattenLiveFileTreeV2(
+  children: (path: string) => readonly FileNode[],
+  expanded: (path: string) => boolean,
+) {
+  const rows: FileTreeV2Row[] = []
+  const stack = children("")
+    .toReversed()
+    .map((node) => ({ node: toLiveNode(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 nested = children(row.node.originalPath)
+    for (let index = nested.length - 1; index >= 0; index--) {
+      stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
+    }
+  }
+
+  return rows
+}
+
+function toLiveNode(node: FileNode): FileTreeV2Node {
+  return {
+    ...node,
+    path: normalizeFileTreeV2Path(node.path),
+    originalPath: node.path,
+  }
+}

+ 60 - 29
packages/app/src/components/file-tree-v2.tsx

@@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 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 {
+  buildFileTreeV2Model,
+  flattenFileTreeV2,
+  flattenLiveFileTreeV2,
+  normalizeFileTreeV2Path,
+  type FileTreeV2Node,
+} from "@/components/file-tree-v2-model"
 import { virtualScrollElement } from "@/components/virtual-scroll-element"
 
 export type { Kind } from "@/components/file-tree"
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
 export const kindLabel = (kind: Kind) => {
   if (kind === "add") return "A"
   if (kind === "del") return "D"
-  return ""
+  return "M"
 }
 
 export const kindChange = (kind: Kind) => {
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
     "class",
     "classList",
   ])
-  const kind = () => local.kinds?.get(local.node.path)
+  const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
 
   return (
     <Dynamic
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
 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`}
-        />
-      )}
+      {(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
     </For>
   )
 }
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
   kinds?: ReadonlyMap<string, Kind>
   draggable?: boolean
   onFileClick?: (file: FileNode) => void
+  onFileDoubleClick?: (file: FileNode) => void
 }) {
   const file = useFile()
+  const live = () => props.allowed === undefined
   const draggable = () => props.draggable ?? true
   const active = () => normalizeFileTreeV2Path(props.active ?? "")
-  const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
-  const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
+  const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
+  const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
+  const rows = createMemo(() => {
+    if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
+    return flattenFileTreeV2(model()!, expanded)
+  })
   const [root, setRoot] = createSignal<HTMLDivElement>()
   const [focused, setFocused] = createSignal<string>()
   const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
       return [...indexes, index].sort((a, b) => a - b)
     },
   })
+
+  createEffect(() => {
+    if (!live()) return
+    void file.tree.list("")
+  })
+
+  // Only scroll when the active path changes (or first appears in the tree).
+  // Do not re-scroll when expand/collapse reshuffles `rows()`.
+  let scrolledActive: string | undefined
   createEffect(() => {
     const path = active()
-    if (!path) return
+    if (!path) {
+      scrolledActive = undefined
+      return
+    }
     const index = rows().findIndex((row) => row.node.path === path)
     if (index < 0) return
+    if (scrolledActive === path) return
+    scrolledActive = path
     queueMicrotask(() => {
-      if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
-      virtualizer.scrollToIndex(index, { align: "auto" })
+      const next = rows().findIndex((row) => row.node.path === path)
+      if (next < 0) return
+      if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
+      virtualizer.scrollToIndex(next, { align: "auto" })
     })
   })
+
+  const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
+    action?.({
+      ...node,
+      path: node.originalPath,
+      absolute: node.originalPath,
+    })
+  }
+
+  const toggleDirectory = (path: string, originalPath: string) => {
+    if (expanded(path)) {
+      file.tree.collapse(originalPath)
+      return
+    }
+    file.tree.expand(originalPath, live() ? undefined : { list: 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)),
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
     <div
       ref={setRoot}
       data-component="file-tree-v2"
-      data-total-rows={model().total}
+      data-total-rows={live() ? rows().length : model()!.total}
       class="group/file-tree-v2"
       style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
     >
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
                           class="relative"
                           onFocus={() => setFocused(row().node.path)}
                           onBlur={() => setFocused(undefined)}
-                          onClick={() =>
-                            props.onFileClick?.({
-                              ...row().node,
-                              path: row().node.originalPath,
-                              absolute: row().node.originalPath,
-                            })
-                          }
+                          onClick={() => selectFile(row().node, props.onFileClick)}
+                          onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
                         >
                           <GuideLines level={row().level} />
                           <Show when={row().level > 0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
                         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)
-                        }
+                        aria-expanded={expanded(row().node.path)}
+                        onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
                       >
                         <GuideLines level={row().level} />
                         <div
                           data-slot="file-tree-v2-chevron"
-                          data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
+                          data-expanded={expanded(row().node.path) ? "" : undefined}
                           class="size-4 flex items-center justify-center"
                         >
                           <Icon name="chevron-down" />

+ 1 - 0
packages/app/src/components/session/index.ts

@@ -1,6 +1,7 @@
 export { SessionHeader } from "./session-header"
 export { SessionContextTab } from "./session-context-tab"
 export { SortableTab, FileVisual } from "./session-sortable-tab"
+export { SortableTabV2 } from "./session-sortable-tab-v2"
 export { SortableTerminalTab } from "./session-sortable-terminal-tab"
 export { NewSessionView } from "./session-new-view"
 export { NewSessionDesignView } from "./session-new-design-view"

+ 98 - 0
packages/app/src/components/session/open-in-app-v2.tsx

@@ -0,0 +1,98 @@
+import { For, Show } from "solid-js"
+import { AppIcon } from "@opencode-ai/ui/app-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useLanguage } from "@/context/language"
+import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
+
+export function OpenInAppV2(props: { directory: () => string }) {
+  const language = useLanguage()
+  const state = useOpenInApp(props)
+
+  return (
+    <Show when={props.directory() && state.canOpen()}>
+      <SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
+        <TooltipV2
+          placement="bottom"
+          value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
+          class="flex items-center"
+        >
+          <SplitButtonV2Action
+            onPointerDown={(event) => event.stopPropagation()}
+            onClick={(event) => {
+              event.stopPropagation()
+              if (state.opening()) return
+              state.openDir(state.current().id)
+            }}
+            disabled={state.opening()}
+            aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
+          >
+            <Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
+              <Spinner class="size-3.5" />
+            </Show>
+          </SplitButtonV2Action>
+        </TooltipV2>
+        <MenuV2
+          gutter={4}
+          modal={false}
+          placement="bottom-end"
+          open={state.menu.open}
+          onOpenChange={(open) => state.setMenu("open", open)}
+        >
+          <MenuV2.Trigger
+            as={SplitButtonV2MenuTrigger}
+            disabled={state.opening()}
+            aria-label={language.t("session.header.open.menu")}
+            onPointerDown={(event) => event.stopPropagation()}
+          >
+            <IconV2 name="chevron-down" size="small" />
+          </MenuV2.Trigger>
+          <MenuV2.Portal>
+            <MenuV2.Content class="open-in-app-v2-menu">
+              <MenuV2.Group>
+                <MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
+                <MenuV2.RadioGroup
+                  value={state.current().id}
+                  onChange={(value) => {
+                    state.selectApp(value as OpenApp)
+                  }}
+                >
+                  <For each={state.options()}>
+                    {(option) => (
+                      <MenuV2.RadioItem
+                        value={option.id}
+                        disabled={state.opening()}
+                        onSelect={() => {
+                          state.selectApp(option.id)
+                          state.setMenu("open", false)
+                          state.openDir(option.id)
+                        }}
+                      >
+                        <AppIcon id={option.icon} />
+                        {option.label}
+                      </MenuV2.RadioItem>
+                    )}
+                  </For>
+                </MenuV2.RadioGroup>
+              </MenuV2.Group>
+              <MenuV2.Separator />
+              <MenuV2.Item
+                onSelect={() => {
+                  state.setMenu("open", false)
+                  state.copyPath()
+                }}
+              >
+                <Icon name="copy" size="small" class="text-icon-weak" />
+                {language.t("session.header.open.copyPath")}
+              </MenuV2.Item>
+            </MenuV2.Content>
+          </MenuV2.Portal>
+        </MenuV2>
+      </SplitButtonV2>
+    </Show>
+  )
+}

+ 229 - 0
packages/app/src/components/session/open-in-app.tsx

@@ -0,0 +1,229 @@
+import { createEffect, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useServer } from "@/context/server"
+import { Persist, persisted } from "@/utils/persist"
+import { showToast } from "@/utils/toast"
+
+export const OPEN_APPS = [
+  "vscode",
+  "cursor",
+  "zed",
+  "textmate",
+  "antigravity",
+  "finder",
+  "terminal",
+  "iterm2",
+  "ghostty",
+  "warp",
+  "xcode",
+  "android-studio",
+  "powershell",
+  "sublime-text",
+] as const
+
+export type OpenApp = (typeof OPEN_APPS)[number]
+export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
+
+export const MAC_OPEN_APPS = [
+  {
+    id: "vscode",
+    label: "session.header.open.app.vscode",
+    icon: "vscode",
+    openWith: "Visual Studio Code",
+  },
+  { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
+  { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
+  { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
+  {
+    id: "antigravity",
+    label: "session.header.open.app.antigravity",
+    icon: "antigravity",
+    openWith: "Antigravity",
+  },
+  { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
+  { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
+  { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
+  { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
+  { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
+  {
+    id: "android-studio",
+    label: "session.header.open.app.androidStudio",
+    icon: "android-studio",
+    openWith: "Android Studio",
+  },
+  {
+    id: "sublime-text",
+    label: "session.header.open.app.sublimeText",
+    icon: "sublime-text",
+    openWith: "Sublime Text",
+  },
+] as const
+
+export const WINDOWS_OPEN_APPS = [
+  { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+  { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+  { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
+  {
+    id: "powershell",
+    label: "session.header.open.app.powershell",
+    icon: "powershell",
+    openWith: "powershell",
+  },
+  {
+    id: "sublime-text",
+    label: "session.header.open.app.sublimeText",
+    icon: "sublime-text",
+    openWith: "Sublime Text",
+  },
+] as const
+
+export const LINUX_OPEN_APPS = [
+  { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+  { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+  { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
+  {
+    id: "sublime-text",
+    label: "session.header.open.app.sublimeText",
+    icon: "sublime-text",
+    openWith: "Sublime Text",
+  },
+] as const
+
+export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
+  if (platform.platform === "desktop" && platform.os) return platform.os
+  if (typeof navigator !== "object") return "unknown"
+  const value = navigator.platform || navigator.userAgent
+  if (/Mac/i.test(value)) return "macos"
+  if (/Win/i.test(value)) return "windows"
+  if (/Linux/i.test(value)) return "linux"
+  return "unknown"
+}
+
+export function openAppFileManager(os: OpenAppOS) {
+  if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
+  if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
+  return { label: "session.header.open.fileManager", icon: "finder" as const }
+}
+
+export function openAppsForOS(os: OpenAppOS) {
+  if (os === "macos") return MAC_OPEN_APPS
+  if (os === "windows") return WINDOWS_OPEN_APPS
+  return LINUX_OPEN_APPS
+}
+
+const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
+  showToast({
+    variant: "error",
+    title: language.t("common.requestFailed"),
+    description: err instanceof Error ? err.message : String(err),
+  })
+}
+
+export function useOpenInApp(input: { directory: () => string }) {
+  const platform = usePlatform()
+  const server = useServer()
+  const language = useLanguage()
+
+  const os = createMemo(() => detectOpenAppOS(platform))
+  const apps = createMemo(() => openAppsForOS(os()))
+  const fileManager = createMemo(() => openAppFileManager(os()))
+
+  const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
+    finder: true,
+  })
+
+  createEffect(() => {
+    if (platform.platform !== "desktop") return
+    if (!platform.checkAppExists) return
+
+    const list = apps()
+
+    setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
+
+    void Promise.all(
+      list.map((app) =>
+        Promise.resolve(platform.checkAppExists?.(app.openWith))
+          .then((value) => Boolean(value))
+          .catch(() => false)
+          .then((ok) => [app.id, ok] as const),
+      ),
+    ).then((entries) => {
+      setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
+    })
+  })
+
+  const options = createMemo(() => {
+    return [
+      { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
+      ...apps()
+        .filter((app) => exists[app.id])
+        .map((app) => ({ ...app, label: language.t(app.label) })),
+    ] as const
+  })
+
+  const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
+  const [menu, setMenu] = createStore({ open: false })
+  const [openRequest, setOpenRequest] = createStore({
+    app: undefined as OpenApp | undefined,
+  })
+
+  const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
+  const current = createMemo(
+    () =>
+      options().find((o) => o.id === prefs.app) ??
+      options()[0] ??
+      ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
+  )
+  const opening = createMemo(() => openRequest.app !== undefined)
+
+  const selectApp = (app: OpenApp | "finder") => {
+    if (!options().some((item) => item.id === app)) return
+    setPrefs("app", app)
+  }
+
+  const openDir = (app: OpenApp | "finder") => {
+    if (opening() || !canOpen() || !platform.openPath) return
+    const directory = input.directory()
+    if (!directory) return
+
+    const item = options().find((o) => o.id === app)
+    const openWith = item && "openWith" in item ? item.openWith : undefined
+    setOpenRequest("app", app)
+    platform
+      .openPath(directory, openWith)
+      .catch((err: unknown) => showRequestError(language, err))
+      .finally(() => {
+        setOpenRequest("app", undefined)
+      })
+  }
+
+  const copyPath = () => {
+    const directory = input.directory()
+    if (!directory) return
+    navigator.clipboard
+      .writeText(directory)
+      .then(() => {
+        showToast({
+          variant: "success",
+          icon: "circle-check",
+          title: language.t("session.share.copy.copied"),
+          description: directory,
+        })
+      })
+      .catch((err: unknown) => showRequestError(language, err))
+  }
+
+  return {
+    canOpen,
+    opening,
+    current,
+    options,
+    menu,
+    setMenu,
+    openDir,
+    selectApp,
+    copyPath,
+  }
+}

+ 66 - 0
packages/app/src/components/session/session-sortable-tab-v2.tsx

@@ -0,0 +1,66 @@
+import { createMemo, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { useSortable } from "@dnd-kit/solid/sortable"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useFile } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
+import { FileVisual } from "./session-sortable-tab"
+
+export function SortableTabV2(props: {
+  tab: string
+  index: () => number
+  temporary?: boolean
+  onTabClose: (tab: string) => void
+  onTabDoubleClick?: (tab: string) => void
+}): JSX.Element {
+  const file = useFile()
+  const language = useLanguage()
+  const command = useCommand()
+  const sortable = useSortable({
+    get id() {
+      return props.tab
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  const path = createMemo(() => file.pathFromTab(props.tab))
+  const content = createMemo(() => {
+    const value = path()
+    if (!value) return
+    return <FileVisual path={value} temporary={props.temporary} />
+  })
+  return (
+    <div ref={sortable.ref} class="h-full flex items-center">
+      <div class="relative">
+        <Tabs.Trigger
+          value={props.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={() => props.onTabClose(props.tab)}
+                aria-label={language.t("common.closeTab")}
+              />
+            </TooltipKeybind>
+          }
+          hideCloseButton
+          onMiddleClick={() => props.onTabClose(props.tab)}
+          onDblClick={() => props.onTabDoubleClick?.(props.tab)}
+        >
+          <Show when={content()}>{(value) => value()}</Show>
+        </Tabs.Trigger>
+      </div>
+    </div>
+  )
+}

+ 12 - 4
packages/app/src/context/layout.tsx

@@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
 }
 
 export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
-  if (key === "orange") return "orange"
-  if (key === "pink") return "pink"
-  if (key === "cyan") return "cyan"
-  if (key === "purple") return "purple"
   if (key === "mint") return "cyan"
   if (key === "lime") return "green"
+  if (
+    key === "orange" ||
+    key === "yellow" ||
+    key === "cyan" ||
+    key === "green" ||
+    key === "red" ||
+    key === "pink" ||
+    key === "blue" ||
+    key === "purple" ||
+    key === "gray"
+  )
+    return key
   return "gray"
 }
 

+ 1 - 1
packages/app/src/i18n/en.ts

@@ -638,7 +638,7 @@ export const dict = {
   "session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
   "session.error.notFound.closeTab": "Close Tab",
   "session.error.serverConnection": "Can't connect to this server",
-  "session.review.filesChanged": "{{count}} Files Changed",
+  "session.review.filesChanged": "Files Changed {{count}}",
   "session.review.change.one": "Change",
   "session.review.change.other": "Changes",
   "session.review.loadingChanges": "Loading changes...",

+ 24 - 5
packages/app/src/pages/home-session-open.test.ts

@@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test"
 import { shouldOpenSessionInBackground } from "./home-session-open"
 
 describe("shouldOpenSessionInBackground", () => {
+  test("opens middle clicks in the background", () => {
+    expect(
+      shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
+    ).toBe(true)
+    expect(
+      shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
+    ).toBe(false)
+  })
+
   test("requires only the platform primary modifier", () => {
-    expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
-    expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
-    expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
-    expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
-    expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
+    expect(
+      shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
+    ).toBe(true)
+    expect(
+      shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
+    ).toBe(true)
+    expect(
+      shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
+    ).toBe(false)
+    expect(
+      shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
+    ).toBe(false)
+    expect(
+      shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
+    ).toBe(false)
   })
 })

+ 3 - 0
packages/app/src/pages/home-session-open.ts

@@ -1,10 +1,13 @@
 export function shouldOpenSessionInBackground(input: {
+  button: number
   mac: boolean
   meta: boolean
   ctrl: boolean
   shift: boolean
   alt: boolean
 }) {
+  if (input.button === 1) return true
+  if (input.button !== 0) return false
   if (input.shift || input.alt) return false
   if (input.mac) return input.meta && !input.ctrl
   return input.ctrl && !input.meta

+ 21 - 4
packages/app/src/pages/home.tsx

@@ -240,10 +240,11 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
   return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
 }
 
-// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
-// background without navigating, matching browser conventions.
+// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
+// tab in the background without navigating, matching browser conventions.
 function isBackgroundOpen(event: MouseEvent) {
   return shouldOpenSessionInBackground({
+    button: event.button,
     mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
     meta: event.metaKey,
     ctrl: event.ctrlKey,
@@ -465,8 +466,8 @@ export function NewHome() {
   }
 
   function editProject(conn: ServerConnection.Any, project: LocalProject) {
-    void import("@/components/dialog-edit-project").then((x) => {
-      dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
+    void import("@/components/dialog-edit-project-v2").then((x) => {
+      void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
     })
   }
 
@@ -1386,7 +1387,15 @@ function HomeSessionSearchResultRow(props: {
         group: !!showProjectName(),
       }}
       onMouseEnter={() => props.onHighlight()}
+      onMouseDown={(event) => {
+        if (event.button === 1) event.preventDefault()
+      }}
       onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
+      onAuxClick={(event) => {
+        if (!isBackgroundOpen(event)) return
+        event.preventDefault()
+        props.onSelect(props.record.session, { background: true })
+      }}
     >
       <HomeSessionLeading
         project={props.record.project}
@@ -1446,7 +1455,15 @@ function HomeSessionRow(props: {
         type="button"
         data-component="home-session-row"
         class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
+        onMouseDown={(event) => {
+          if (event.button === 1) event.preventDefault()
+        }}
         onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
+        onAuxClick={(event) => {
+          if (!isBackgroundOpen(event)) return
+          event.preventDefault()
+          props.openSession(props.record.session, { background: true })
+        }}
       >
         <HomeSessionLeading
           project={props.record.project}

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

@@ -79,6 +79,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
 import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
 import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
+import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
 import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
 import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
 import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@@ -2276,6 +2277,13 @@ export default function Page() {
                     reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
                     reviewCount={reviewCount}
                     reviewPanel={reviewPanelV2}
+                    reviewSidebarToggle={(disabled) => (
+                      <SessionReviewV2SidebarToggle
+                        opened={reviewV2State.sidebarOpened()}
+                        disabled={disabled}
+                        onToggle={reviewV2State.toggleSidebar}
+                      />
+                    )}
                     fileBrowserState={reviewV2State}
                     activeDiff={activeReviewFile()}
                     focusReviewDiff={focusReviewDiff}

+ 329 - 1
packages/app/src/pages/session/file-tabs.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { makeEventListener } from "@solid-primitives/event-listener"
@@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
 import { useFileComponent } from "@opencode-ai/ui/context/file"
 import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
 import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
+import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
 import { sampledChecksum } from "@opencode-ai/core/util/encode"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { IconButton } from "@opencode-ai/ui/icon-button"
+import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { showToast } from "@/utils/toast"
@@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
 import { useComments } from "@/context/comments"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
+import { useSettings } from "@/context/settings"
 import { getSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
@@ -53,6 +57,30 @@ function FileCommentMenu(props: {
   )
 }
 
+function FileCommentMenuV2(props: {
+  moreLabel: string
+  editLabel: string
+  deleteLabel: string
+  onEdit: VoidFunction
+  onDelete: VoidFunction
+}) {
+  return (
+    <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+      <MenuV2 gutter={4}>
+        <MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
+          <LineCommentV2OverflowIcon />
+        </MenuV2.Trigger>
+        <MenuV2.Portal>
+          <MenuV2.Content>
+            <MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
+            <MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
+          </MenuV2.Content>
+        </MenuV2.Portal>
+      </MenuV2>
+    </div>
+  )
+}
+
 type ScrollPos = { x: number; y: number }
 
 function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
@@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
 }
 
 export function SessionFileView(props: { tab: string }) {
+  const settings = useSettings()
+  return (
+    <Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
+      <SessionFileViewV2 tab={props.tab} />
+    </Show>
+  )
+}
+
+function SessionFileViewV1(props: { tab: string }) {
   const file = useFile()
   const comments = useComments()
   const language = useLanguage()
@@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
 
   return content()
 }
+
+function SessionFileViewV2(props: { tab: string }) {
+  const file = useFile()
+  const comments = useComments()
+  const language = useLanguage()
+  const prompt = usePrompt()
+  const fileComponent = useFileComponent()
+  const { sessionKey, tabs, view } = useSessionLayout()
+  const activeFileTab = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+  }).activeFileTab
+
+  let find: FileSearchHandle | null = null
+
+  const search = {
+    register: (handle: FileSearchHandle | null) => {
+      find = handle
+    },
+  }
+
+  const path = createMemo(() => file.pathFromTab(props.tab))
+  const state = createMemo(() => {
+    const p = path()
+    if (!p) return
+    return file.get(p)
+  })
+  const contents = createMemo(() => state()?.content?.content ?? "")
+  const cacheKey = createMemo(() => sampledChecksum(contents()))
+  const selectedLines = createMemo<SelectedLineRange | null>(() => {
+    const p = path()
+    if (!p) return null
+    if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
+    return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
+  })
+  const scrollSync = createScrollSync({
+    tab: () => props.tab,
+    view,
+  })
+
+  const selectionPreview = (source: string, selection: FileSelection) => {
+    return previewSelectedLines(source, {
+      start: selection.startLine,
+      end: selection.endLine,
+    })
+  }
+
+  const buildPreview = (filePath: string, selection: FileSelection) => {
+    const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
+    if (!source) return undefined
+    return selectionPreview(source, selection)
+  }
+
+  const addCommentToContext = (input: {
+    file: string
+    selection: SelectedLineRange
+    comment: string
+    preview?: string
+    origin?: "review" | "file"
+  }) => {
+    const selection = selectionFromLines(input.selection)
+    const preview = input.preview ?? buildPreview(input.file, selection)
+
+    const saved = comments.add({
+      file: input.file,
+      selection: input.selection,
+      comment: input.comment,
+    })
+    prompt.context.add({
+      type: "file",
+      path: input.file,
+      selection,
+      comment: input.comment,
+      commentID: saved.id,
+      commentOrigin: input.origin,
+      preview,
+    })
+  }
+
+  const updateCommentInContext = (input: {
+    id: string
+    file: string
+    selection: SelectedLineRange
+    comment: string
+  }) => {
+    comments.update(input.file, input.id, input.comment)
+    const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
+    prompt.context.updateComment(input.file, input.id, {
+      comment: input.comment,
+      ...(preview ? { preview } : {}),
+    })
+  }
+
+  const removeCommentFromContext = (input: { id: string; file: string }) => {
+    comments.remove(input.file, input.id)
+    prompt.context.removeComment(input.file, input.id)
+  }
+
+  const fileComments = createMemo(() => {
+    const p = path()
+    if (!p) return []
+    return comments.list(p)
+  })
+
+  const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
+
+  const [note, setNote] = createStore({
+    openedComment: null as string | null,
+    commenting: null as SelectedLineRange | null,
+    selected: null as SelectedLineRange | null,
+  })
+
+  const syncSelected = (range: SelectedLineRange | null) => {
+    const p = path()
+    if (!p) return
+    file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
+  }
+
+  const activeSelection = () => note.selected ?? selectedLines()
+
+  const commentsUi = createLineCommentControllerV2({
+    comments: fileComments,
+    label: language.t("ui.lineComment.submit"),
+    draftKey: () => path() ?? props.tab,
+    mention: {
+      items: file.searchFilesAndDirectories,
+    },
+    getSide: (range) => range.endSide ?? range.side ?? "additions",
+    state: {
+      opened: () => note.openedComment,
+      setOpened: (id) => setNote("openedComment", id),
+      selected: () => note.selected,
+      setSelected: (range) => setNote("selected", range),
+      commenting: () => note.commenting,
+      setCommenting: (range) => setNote("commenting", range),
+      syncSelected,
+      hoverSelected: syncSelected,
+    },
+    onSubmit: ({ comment, selection }) => {
+      const p = path()
+      if (!p) return
+      addCommentToContext({ file: p, selection, comment, origin: "file" })
+    },
+    onUpdate: ({ id, comment, selection }) => {
+      const p = path()
+      if (!p) return
+      updateCommentInContext({ id, file: p, selection, comment })
+    },
+    onDelete: (comment) => {
+      const p = path()
+      if (!p) return
+      removeCommentFromContext({ id: comment.id, file: p })
+    },
+    editSubmitLabel: language.t("common.save"),
+    renderCommentActions: (_, controls) => (
+      <FileCommentMenuV2
+        moreLabel={language.t("common.moreOptions")}
+        editLabel={language.t("common.edit")}
+        deleteLabel={language.t("common.delete")}
+        onEdit={controls.edit}
+        onDelete={controls.remove}
+      />
+    ),
+  })
+
+  createEffect(() => {
+    if (typeof window === "undefined") return
+
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (activeFileTab() !== props.tab) return
+      if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
+      if (event.key.toLowerCase() !== "f") return
+
+      event.preventDefault()
+      event.stopPropagation()
+      find?.focus()
+    }
+
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
+  })
+
+  createEffect(
+    on(
+      path,
+      () => {
+        commentsUi.note.reset()
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(() => {
+    const focus = comments.focus()
+    const p = path()
+    if (!focus || !p) return
+    if (focus.file !== p) return
+    if (activeFileTab() !== props.tab) return
+
+    const target = fileComments().find((comment) => comment.id === focus.id)
+    if (!target) return
+
+    commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
+    requestAnimationFrame(() => comments.clearFocus())
+  })
+
+  let prev = {
+    loaded: false,
+    ready: false,
+    active: false,
+  }
+
+  createEffect(() => {
+    const loaded = !!state()?.loaded
+    const ready = file.ready()
+    const active = activeFileTab() === props.tab
+    const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
+    prev = { loaded, ready, active }
+    if (!restore) return
+    scrollSync.queueRestore()
+  })
+
+  const renderFile = (source: string) => (
+    <div class="relative overflow-hidden pb-40">
+      <Dynamic
+        component={fileComponent}
+        mode="text"
+        file={{
+          name: path() ?? "",
+          contents: source,
+          cacheKey: cacheKey(),
+        }}
+        enableLineSelection
+        enableGutterUtility
+        selectedLines={activeSelection()}
+        commentedLines={commentedLines()}
+        onRendered={() => {
+          scrollSync.queueRestore()
+        }}
+        annotations={commentsUi.annotations()}
+        renderAnnotation={commentsUi.renderAnnotation}
+        renderGutterUtility={commentsUi.renderGutterUtility}
+        onLineSelected={(range: SelectedLineRange | null) => {
+          commentsUi.onLineSelected(range)
+        }}
+        onLineSelectionEnd={(range: SelectedLineRange | null) => {
+          if (!range) {
+            commentsUi.note.select(null)
+            commentsUi.note.cancelDraft()
+            return
+          }
+          commentsUi.onLineSelectionEnd(range)
+        }}
+        onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
+          commentsUi.onLineNumberSelectionEnd(range)
+        }}
+        search={search}
+        class="select-text"
+        media={{
+          mode: "auto",
+          path: path(),
+          current: state()?.content,
+          onLoad: scrollSync.queueRestore,
+          onError: (args: { kind: "image" | "audio" | "svg" }) => {
+            if (args.kind !== "svg") return
+            showToast({
+              variant: "error",
+              title: language.t("toast.file.loadFailed.title"),
+            })
+          },
+        }}
+      />
+    </div>
+  )
+
+  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>
+          <Match when={state()?.loading}>
+            <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
+          </Match>
+          <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
+        </Switch>
+      </ScrollView>
+    </div>
+  )
+
+  return content()
+}

+ 409 - 157
packages/app/src/pages/session/session-side-panel.tsx

@@ -1,28 +1,46 @@
 import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createMediaQuery } from "@solid-primitives/media"
+import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable } from "@dnd-kit/solid/sortable"
+import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import {
+  DragDropProvider,
+  DragDropSensors,
+  DragOverlay,
+  SortableProvider,
+  closestCenter,
+  type DragEvent,
+} from "@thisbeyond/solid-dnd"
 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"
-import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
-import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 
 import FileTree from "@/components/file-tree"
+import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
 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"
+const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
+import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
+import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
 import { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
 import { useSettings } from "@/context/settings"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
@@ -53,6 +71,7 @@ export function SessionSidePanel(props: {
   reviewHasFocusableContent: () => boolean
   reviewCount: () => number
   reviewPanel: () => JSX.Element
+  reviewSidebarToggle?: (disabled: boolean) => JSX.Element
   fileBrowserState?: SessionFileBrowserState
   activeDiff?: string
   focusReviewDiff: (path: string) => void
@@ -66,7 +85,9 @@ export function SessionSidePanel(props: {
   const language = useLanguage()
   const command = useCommand()
   const dialog = useDialog()
+  const sdk = useSDK()
   const { sessionKey, tabs, view, params } = useSessionLayout()
+  const projectDirectory = createMemo(() => sdk().directory)
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const shown = settings.visibility.fileTree
@@ -98,11 +119,9 @@ export function SessionSidePanel(props: {
       return "mix" as const
     }
 
-    const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
-
     const out = new Map<string, "add" | "del" | "mix">()
     for (const diff of diffs()) {
-      const file = normalize(diff.file)
+      const file = normalizeFileTreeV2Path(diff.file)
       const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
 
       out.set(file, kind)
@@ -159,6 +178,7 @@ export function SessionSidePanel(props: {
     fileBrowser: () => !!props.fileBrowserState,
   })
   const contextOpen = tabState.contextOpen
+  const openFileOpen = tabState.openFileOpen
   const panelTabs = tabState.panelTabs
   const openedTabs = tabState.openedTabs
   const activeTab = tabState.activeTab
@@ -176,10 +196,8 @@ export function SessionSidePanel(props: {
     layout.fileTree.setTab("all")
   }
 
-  const [store, setStore] = createStore({
-    activeDraggable: undefined as string | undefined,
-  })
   let fileFilter: HTMLInputElement | undefined
+  let tabList: HTMLDivElement | undefined
   const temporaryTab = tabs().preview
   const previewTab = (value: string) => {
     const next = normalizeTab(value)
@@ -202,10 +220,26 @@ export function SessionSidePanel(props: {
   }
   const browserTab = createMemo(() => {
     if (!props.fileBrowserState) return undefined
-    if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
+    const active = activeTab()
+    if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
+    if (active && file.pathFromTab(active)) return active
     return activeFileTab()
   })
-  const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
+  // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
+  // selects Review while the tab For replaces a preview trigger, which would
+  // otherwise dispose the sidebar and reset scroll.
+  const fileBrowserMounted = createMemo(() => {
+    if (!props.fileBrowserState) return false
+    return openedTabs().length > 0 || openFileOpen() || !!browserTab()
+  })
+  const fileBrowserVisible = createMemo(() => {
+    const active = activeTab()
+    return active !== "review" && active !== "context" && active !== "empty"
+  })
+  const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
+  const [store, setStore] = createStore({
+    activeDraggable: undefined as string | undefined,
+  })
 
   const handleDragStart = (event: unknown) => {
     const id = getDraggableId(event)
@@ -291,72 +325,283 @@ export function SessionSidePanel(props: {
                     "bg-background-base": !settings.general.newLayoutDesigns(),
                   }}
                 >
-                  <DragDropProvider
-                    onDragStart={handleDragStart}
-                    onDragEnd={handleDragEnd}
-                    onDragOver={handleDragOver}
-                    collisionDetector={closestCenter}
-                  >
-                    <DragDropSensors />
-                    <ConstrainDragYAxis />
-                    <Tabs value={activeTab()} onChange={activateTab}>
-                      <div class="sticky top-0 shrink-0 flex">
-                        <Tabs.List
-                          ref={(el: HTMLDivElement) => {
-                            const stop = createFileTabListSync({ el, contextOpen })
-                            onCleanup(stop)
-                          }}
-                        >
-                          <Show when={reviewTab() && props.canReview()}>
-                            <Tabs.Trigger
-                              value="review"
-                              id={reviewTabID}
-                              aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
+                  <Show
+                    when={props.fileBrowserState}
+                    fallback={
+                      <DragDropProvider
+                        onDragStart={handleDragStart}
+                        onDragEnd={handleDragEnd}
+                        onDragOver={handleDragOver}
+                        collisionDetector={closestCenter}
+                      >
+                        <DragDropSensors />
+                        <ConstrainDragYAxis />
+                        <Tabs value={activeTab()} onChange={activateTab}>
+                          <div class="sticky top-0 shrink-0 flex">
+                            <Tabs.List
+                              ref={(el: HTMLDivElement) => {
+                                const stop = createFileTabListSync({ el, contextOpen })
+                                onCleanup(stop)
+                              }}
                             >
-                              <div class="flex items-center gap-1.5">
-                                <div>{language.t("session.tab.review")}</div>
-                                <Show when={props.hasReview()}>
-                                  <div>{props.reviewCount()}</div>
-                                </Show>
-                              </div>
-                            </Tabs.Trigger>
-                          </Show>
-                          <Show when={contextOpen()}>
-                            <Tabs.Trigger
-                              value="context"
-                              closeButton={
+                              <Show when={reviewTab() && props.canReview()}>
+                                <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()}>
+                                      <div>{props.reviewCount()}</div>
+                                    </Show>
+                                  </div>
+                                </Tabs.Trigger>
+                              </Show>
+                              <Show when={contextOpen()}>
+                                <Tabs.Trigger
+                                  value="context"
+                                  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("context")}
+                                        aria-label={language.t("common.closeTab")}
+                                      />
+                                    </TooltipKeybind>
+                                  }
+                                  hideCloseButton
+                                  onMiddleClick={() => tabs().close("context")}
+                                >
+                                  <div class="flex items-center gap-2">
+                                    <SessionContextUsage variant="indicator" />
+                                    <div>{language.t("session.tab.context")}</div>
+                                  </div>
+                                </Tabs.Trigger>
+                              </Show>
+                              <SortableProvider ids={openedTabs()}>
+                                <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="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
+                                classList={{
+                                  "bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
+                                  "bg-background-stronger": !settings.general.newLayoutDesigns(),
+                                }}
+                              >
                                 <TooltipKeybind
-                                  title={language.t("common.closeTab")}
-                                  keybind={command.keybind("tab.close")}
-                                  placement="bottom"
-                                  gutter={10}
+                                  title={language.t("command.file.open")}
+                                  keybind={command.keybind("file.open")}
+                                  class="flex items-center"
                                 >
                                   <IconButton
-                                    icon="close-small"
+                                    icon="plus-small"
                                     variant="ghost"
-                                    class="h-5 w-5"
-                                    onClick={() => tabs().close("context")}
-                                    aria-label={language.t("common.closeTab")}
+                                    iconSize="large"
+                                    class="!rounded-md"
+                                    onClick={() => {
+                                      void import("@/components/dialog-select-file").then((x) => {
+                                        dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
+                                      })
+                                    }}
+                                    aria-label={language.t("command.file.open")}
                                   />
                                 </TooltipKeybind>
-                              }
-                              hideCloseButton
-                              onMiddleClick={() => tabs().close("context")}
+                              </div>
+                            </Tabs.List>
+                          </div>
+
+                          <Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
+                            <div
+                              id={reviewTabPanelID}
+                              role="tabpanel"
+                              aria-labelledby={reviewTabID}
+                              tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
+                              data-slot="tabs-content"
+                              class="flex flex-col h-full overflow-hidden contain-strict"
                             >
-                              <div class="flex items-center gap-2">
-                                <SessionContextUsage variant="indicator" />
-                                <div>{language.t("session.tab.context")}</div>
+                              {props.reviewPanel()}
+                            </div>
+                          </Show>
+
+                          <Show when={activeTab() === "empty"}>
+                            <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+                              <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                                <div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
+                                  <Mark class="w-14 opacity-10" />
+                                  <div class="text-14-regular text-text-weak max-w-56">
+                                    {language.t("session.files.selectToOpen")}
+                                  </div>
+                                </div>
+                              </div>
+                            </Tabs.Content>
+                          </Show>
+
+                          <Show when={activeTab() === "context"}>
+                            <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                              <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                                <SessionContextTab />
                               </div>
-                            </Tabs.Trigger>
+                            </Tabs.Content>
+                          </Show>
+
+                          <Show when={activeFileTab()} keyed>
+                            {(tab) => <FileTabContent tab={tab} />}
+                          </Show>
+                        </Tabs>
+                        <DragOverlay>
+                          <Show when={store.activeDraggable} keyed>
+                            {(tab) => {
+                              const path = file.pathFromTab(tab)
+                              return (
+                                <div data-component="tabs-drag-preview">
+                                  <Show when={path}>
+                                    {(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
+                                  </Show>
+                                </div>
+                              )
+                            }}
                           </Show>
-                          <SortableProvider ids={openedTabs()}>
+                        </DragOverlay>
+                      </DragDropProvider>
+                    }
+                  >
+                    <DndKitProvider
+                      sensors={[
+                        PointerSensor.configure({
+                          activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
+                          preventActivation: (event) =>
+                            event.target instanceof Element &&
+                            (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
+                              !!event.target.closest(".session-review-v2-open-in-app-slot")),
+                        }),
+                      ]}
+                      modifiers={[
+                        RestrictToHorizontalAxis,
+                        RestrictToElement.configure({ element: () => tabList ?? null }),
+                      ]}
+                      plugins={(defaults) => [
+                        ...defaults.filter((plugin) => plugin !== Accessibility),
+                        AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
+                        Feedback.configure({ dropAnimation: null }),
+                      ]}
+                      onDragEnd={(event) => {
+                        const source = event.operation.source
+                        if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
+                        tabs().move(source.id.toString(), source.index)
+                      }}
+                    >
+                      <Tabs value={activeTab()} onChange={activateTab}>
+                        <div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
+                          <Tabs.List
+                            ref={(el: HTMLDivElement) => {
+                              tabList = el
+                              const stop = createFileTabListSync({ el, contextOpen })
+                              onCleanup(stop)
+                            }}
+                          >
+                            <Show when={props.reviewSidebarToggle}>
+                              {(toggle) => (
+                                <div class="h-full shrink-0 flex items-center justify-center">
+                                  {toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
+                                </div>
+                              )}
+                            </Show>
+                            <Show when={reviewTab() && props.canReview()}>
+                              <Tabs.Trigger
+                                value="review"
+                                id={reviewTabID}
+                                aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
+                              >
+                                {props.hasReview()
+                                  ? language.t("session.review.filesChanged", { count: props.reviewCount() })
+                                  : language.t("session.tab.review")}
+                              </Tabs.Trigger>
+                            </Show>
+                            <Show when={contextOpen()}>
+                              <Tabs.Trigger
+                                value="context"
+                                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("context")}
+                                      aria-label={language.t("common.closeTab")}
+                                    />
+                                  </TooltipKeybind>
+                                }
+                                hideCloseButton
+                                onMiddleClick={() => tabs().close("context")}
+                              >
+                                <div class="flex items-center gap-2">
+                                  <SessionContextUsage variant="indicator" />
+                                  <div>{language.t("session.tab.context")}</div>
+                                </div>
+                              </Tabs.Trigger>
+                            </Show>
                             <For each={panelTabs()}>
                               {(tab) => (
                                 <Show
                                   when={tab === SESSION_OPEN_FILE_TAB}
                                   fallback={
-                                    <SortableTab
+                                    <SortableTabV2
                                       tab={tab}
+                                      index={() => tabs().all().indexOf(tab)}
                                       temporary={temporaryTab() === tab}
                                       onTabClose={tabs().close}
                                       onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@@ -392,106 +637,104 @@ export function SessionSidePanel(props: {
                                 </Show>
                               )}
                             </For>
-                          </SortableProvider>
+                            <div
+                              class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
+                              classList={{
+                                "bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
+                                "bg-background-stronger": !settings.general.newLayoutDesigns(),
+                              }}
+                            >
+                              <TooltipV2
+                                value={
+                                  <>
+                                    {language.t("command.file.open")}
+                                    <Show when={openFileKeybind().length > 0}>
+                                      <KeybindV2 keys={openFileKeybind()} variant="neutral" />
+                                    </Show>
+                                  </>
+                                }
+                                placement="bottom"
+                                class="flex items-center"
+                              >
+                                <IconButtonV2
+                                  icon={<Icon name="plus-small" />}
+                                  variant="ghost-muted"
+                                  size="large"
+                                  onClick={() => openFileBrowser()}
+                                  aria-label={language.t("command.file.open")}
+                                />
+                              </TooltipV2>
+                            </div>
+                          </Tabs.List>
                           <div
-                            class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
-                            classList={{
-                              "bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
-                              "bg-background-stronger": !settings.general.newLayoutDesigns(),
-                            }}
+                            class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
+                            onPointerDown={(event) => event.stopPropagation()}
+                            onClick={(event) => event.stopPropagation()}
                           >
-                            <TooltipKeybind
-                              title={language.t("command.file.open")}
-                              keybind={command.keybind("file.open")}
-                              class="flex items-center"
-                            >
-                              <IconButton
-                                icon="plus-small"
-                                variant="ghost"
-                                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} />)
-                                  })
-                                }}
-                                aria-label={language.t("command.file.open")}
-                              />
-                            </TooltipKeybind>
+                            <OpenInAppV2 directory={projectDirectory} />
                           </div>
-                        </Tabs.List>
-                      </div>
-
-                      <Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
-                        <div
-                          id={reviewTabPanelID}
-                          role="tabpanel"
-                          aria-labelledby={reviewTabID}
-                          tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
-                          data-slot="tabs-content"
-                          class="flex flex-col h-full overflow-hidden contain-strict"
-                        >
-                          {props.reviewPanel()}
                         </div>
-                      </Show>
-
-                      <Show when={activeTab() === "empty"}>
-                        <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
-                          <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                            <div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
-                              <Mark class="w-14 opacity-10" />
-                              <div class="text-14-regular text-text-weak max-w-56">
-                                {language.t("session.files.selectToOpen")}
+
+                        <Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
+                          <div
+                            id={reviewTabPanelID}
+                            role="tabpanel"
+                            aria-labelledby={reviewTabID}
+                            tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
+                            data-slot="tabs-content"
+                            class="flex flex-col h-full overflow-hidden contain-strict"
+                          >
+                            {props.reviewPanel()}
+                          </div>
+                        </Show>
+
+                        <Show when={activeTab() === "empty"}>
+                          <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+                            <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                              <div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
+                                <Mark class="w-14 opacity-10" />
+                                <div class="text-14-regular text-text-weak max-w-56">
+                                  {language.t("session.files.selectToOpen")}
+                                </div>
                               </div>
                             </div>
-                          </div>
-                        </Tabs.Content>
-                      </Show>
+                          </Tabs.Content>
+                        </Show>
 
-                      <Show when={activeTab() === "context"}>
-                        <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
-                          <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                            <SessionContextTab />
-                          </div>
-                        </Tabs.Content>
-                      </Show>
-
-                      <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>
-                    <DragOverlay>
-                      <Show when={store.activeDraggable} keyed>
-                        {(tab) => {
-                          const path = file.pathFromTab(tab)
-                          return (
-                            <div data-component="tabs-drag-preview">
-                              <Show when={path}>
-                                {(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
-                              </Show>
+                        <Show when={activeTab() === "context"}>
+                          <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                            <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                              <SessionContextTab />
                             </div>
-                          )
-                        }}
-                      </Show>
-                    </DragOverlay>
-                  </DragDropProvider>
+                          </Tabs.Content>
+                        </Show>
+
+                        <Show when={fileBrowserMounted()}>
+                          <div
+                            id={fileBrowserTabPanelID}
+                            role="tabpanel"
+                            data-slot="tabs-content"
+                            class="h-full min-h-0 overflow-hidden"
+                            classList={{ hidden: !fileBrowserVisible() }}
+                            inert={!fileBrowserVisible() || undefined}
+                          >
+                            <SessionFileBrowserTab
+                              tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
+                              placeholder={
+                                (browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
+                              }
+                              active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
+                              kinds={kinds()}
+                              state={props.fileBrowserState!}
+                              onSelect={(path) => previewTab(file.tab(path))}
+                              onSelectPermanent={(path) => openTab(file.tab(path))}
+                              filterRef={(element) => (fileFilter = element)}
+                            />
+                          </div>
+                        </Show>
+                      </Tabs>
+                    </DndKitProvider>
+                  </Show>
                 </div>
               </div>
             </Show>
@@ -519,10 +762,19 @@ export function SessionSidePanel(props: {
                   >
                     <Tabs.List>
                       <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
-                        {props.reviewCount()}{" "}
-                        {language.t(
-                          props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
-                        )}
+                        <Show
+                          when={settings.general.newLayoutDesigns()}
+                          fallback={
+                            <>
+                              {props.reviewCount()}{" "}
+                              {language.t(
+                                props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
+                              )}
+                            </>
+                          }
+                        >
+                          {language.t("session.review.filesChanged", { count: props.reviewCount() })}
+                        </Show>
                       </Tabs.Trigger>
                       <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
                         {language.t("session.files.all")}

+ 3 - 5
packages/app/src/pages/session/v2/review-panel-v2.tsx

@@ -5,7 +5,6 @@ import {
   SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
   SessionReviewV2,
   SessionReviewV2Sidebar,
-  SessionReviewV2SidebarToggle,
 } from "@opencode-ai/session-ui/v2/session-review-v2"
 import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
 import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -66,6 +65,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
   )
   const searching = createMemo(() => props.state.filter().trim().length > 0)
   const kinds = createMemo(() => reviewDiffKinds(diffs()))
+  // Changes-only trees omit "M" — every row is already a change; A/D stay visible.
+  const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
   const activeDiff = createMemo(() => {
     // A focused comment takes over the preview until the preview applies it and
     // clears the focus; the owner then persists the file as the active selection.
@@ -113,9 +114,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
       stats={<DiffChanges changes={diffs()} />}
       empty={props.empty}
       sidebarOpen={props.state.sidebarOpened()}
-      sidebarToggle={
-        <SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
-      }
       sidebar={
         // Always mounted: the sidebar header hosts the changes-mode dropdown,
         // which must stay reachable when the current mode has zero diffs.
@@ -127,7 +125,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
           diffs={diffs}
           filteredFiles={filteredFiles}
           searching={searching}
-          kinds={kinds}
+          kinds={treeKinds}
           activeDiff={activeDiff}
         />
       }

+ 74 - 89
packages/app/src/pages/session/v2/session-file-browser-tab.tsx

@@ -1,14 +1,9 @@
 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 { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
+import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
+import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
@@ -93,102 +88,92 @@ export function SessionFileBrowserTab(props: {
     })
   }
 
+  // Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
+  // unmounts the whole panel on every file-tab switch and resets sidebar scroll.
   return (
-    <Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
-      <SessionFilePanelV2
-        toolbar
-        toolbarStart={
-          <>
-            <SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
-            <Show when={!sidebarOpened()}>
-              <SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
-            </Show>
-          </>
-        }
-        sidebar={
-          <SessionReviewV2Sidebar
-            open={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}
+    <SessionFilePanelV2
+      toolbar={false}
+      sidebar={
+        <SessionReviewV2Sidebar
+          open={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={
+              <FileTreeV2
+                active={props.active}
+                kinds={props.kinds}
+                onFileClick={(node) => props.onSelect(node.path)}
+                onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
+              />
+            }
           >
             <Show
-              when={query()}
+              when={!loading()}
               fallback={
-                <FileTree
-                  path=""
-                  class="pt-1"
-                  active={props.active}
-                  kinds={props.kinds}
-                  onFileClick={(node) => props.onSelect(node.path)}
-                  onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
-                />
+                <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={!loading()}
+                when={files().length > 0}
                 fallback={
                   <div role="status" class="px-2 py-2 text-12-regular text-text-weak">
-                    {language.t("common.loading")}
-                    {language.t("common.loading.ellipsis")}
+                    {language.t("palette.empty")}
                   </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>
+                <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>
-          </SessionReviewV2Sidebar>
+          </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>
         }
       >
-        <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>
+        <div class="min-h-0 flex-1">
+          <Show when={props.tab} keyed>
+            {(tab) => <SessionFileView tab={tab} />}
+          </Show>
+        </div>
+      </Show>
+    </SessionFilePanelV2>
   )
 }

+ 8 - 0
packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx

@@ -11,6 +11,7 @@ import {
 import { useI18n } from "@opencode-ai/ui/context/i18n"
 import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
 import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2"
+import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2"
 
 type LineCommentControllerV2Props<T extends LineCommentShape> = {
   comments: Accessor<T[]>
@@ -23,6 +24,7 @@ type LineCommentControllerV2Props<T extends LineCommentShape> = {
   onDelete?: (comment: T) => void
   renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
   editSubmitLabel?: string
+  mention?: LineCommentEditorV2Mention
 }
 
 type CommentProps = {
@@ -43,6 +45,7 @@ type DraftProps = {
   onSubmit: (value: string) => void
   cancelLabel?: string
   submitLabel?: string
+  mention?: LineCommentEditorV2Mention
 }
 
 function lineCommentElementV2(view: Accessor<CommentProps>) {
@@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
           onSubmit={view().editor!.onSubmit}
           cancelLabel={view().editor!.cancelLabel}
           submitLabel={view().editor!.submitLabel}
+          mention={view().editor!.mention}
         />
       </div>
     </Show>
@@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
         onSubmit={view().onSubmit}
         cancelLabel={view().cancelLabel}
         submitLabel={view().submitLabel}
+        mention={view().mention}
       />
     </div>
   )
@@ -142,6 +147,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
                 },
                 cancelLabel: i18n.t("ui.lineComment.cancel"),
                 submitLabel: props.editSubmitLabel,
+                mention: props.mention,
               }
             : undefined
         },
@@ -165,6 +171,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
       },
       cancelLabel: i18n.t("ui.lineComment.cancel"),
       submitLabel: i18n.t("ui.lineComment.submit"),
+      mention: props.mention,
     }),
   })
 
@@ -202,6 +209,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
   }
 
   return {
+    note,
     annotations,
     renderAnnotation,
     renderGutterUtility,

+ 6 - 1
packages/session-ui/src/v2/components/session-review-v2.css

@@ -76,6 +76,11 @@
   color: var(--text-strong, var(--v2-text-text-base));
 }
 
+[data-component="session-review-v2-sidebar-root"]
+  [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) {
+  margin-left: 8px;
+}
+
 [data-component="session-review-v2-sidebar-root"]
   [data-slot="session-review-v2-sidebar-title"]
   [data-component="select-v2-root"] {
@@ -97,7 +102,7 @@
   width: 100%;
   max-width: 100%;
   min-width: 0;
-  margin-top: 2px;
+  margin-top: 3px;
 }
 
 [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {

+ 2 - 3
packages/session-ui/src/v2/components/session-review-v2.tsx

@@ -26,7 +26,6 @@ export type SessionReviewV2Props = {
   empty?: JSX.Element
   sidebarOpen?: boolean
   sidebar?: JSX.Element
-  sidebarToggle?: JSX.Element
   activeFile?: string
   files: string[]
   onSelectFile: (file: string) => void
@@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
 
   const toolbarStart = () => (
     <>
-      {props.sidebarToggle}
       <Show when={showCollapsedMeta()}>
         <div data-slot="session-review-v2-toolbar-collapsed-meta">
           <Show when={title()}>
@@ -319,7 +317,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
   )
 }
 
-export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) {
+export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) {
   const i18n = useI18n()
 
   return (
@@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle:
         class="session-review-v2-sidebar-toggle"
         aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
         aria-expanded={props.opened}
+        disabled={props.disabled}
         onClick={props.onToggle}
         icon={<Icon name="filetree" />}
       />

+ 101 - 0
packages/ui/src/components/tabs.css

@@ -698,3 +698,104 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm
     }
   }
 }
+
+body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] {
+  [data-slot="tabs-list"] {
+    height: 52px;
+    padding-right: 12px;
+    gap: 8px;
+    --tabs-review-fade: 8px;
+  }
+
+  [data-slot="tabs-trigger-wrapper"] {
+    height: 28px;
+    padding-inline: 10px;
+    border: 0;
+    background-color: transparent;
+    box-shadow: none;
+
+    &::after {
+      display: none;
+    }
+
+    [data-slot="tabs-trigger"] {
+      padding: 0 !important;
+    }
+
+    &:has([data-slot="tabs-trigger-close-button"]) {
+      padding-right: 10px;
+    }
+
+    &:has([data-selected]) {
+      background-color: var(--v2-background-bg-layer-02);
+      box-shadow: none;
+    }
+  }
+
+  [data-slot="tabs-trigger"] {
+    font-family: Inter, sans-serif;
+    font-style: normal;
+    font-weight: 440;
+    font-size: 13px;
+    line-height: 16px;
+    letter-spacing: -0.04px;
+    color: var(--v2-text-text-muted);
+    font-variation-settings: "slnt" 0;
+    font-variant-numeric: tabular-nums;
+
+    &[data-selected] {
+      color: var(--v2-text-text-base);
+    }
+
+    .tab-fileicon-color,
+    .tab-fileicon-mono,
+    [data-slot="icon-svg"] {
+      margin-left: -2px;
+    }
+  }
+
+  /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */
+  [data-slot="tabs-trigger"] .italic {
+    font-style: italic !important;
+    font-variation-settings: "slnt" -10 !important;
+  }
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar {
+  width: 100%;
+  min-width: 0;
+  gap: 8px;
+  box-sizing: border-box;
+  border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
+  background-color: var(--v2-background-bg-base);
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"],
+body[data-new-layout]
+  #review-panel
+  [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"]
+  .session-review-v2-tabs-bar
+  [data-slot="tabs-list"] {
+  flex: 1 1 0;
+  min-width: 0;
+  width: auto;
+  border-bottom: none;
+}
+
+body[data-new-layout]
+  #review-panel
+  [data-component="tabs"]
+  .session-review-v2-tabs-bar
+  [data-slot="tabs-list"]
+  > .sticky {
+  padding-right: 0;
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot {
+  flex-shrink: 0;
+  margin-left: auto;
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app {
+  flex-shrink: 0;
+}

+ 18 - 1
packages/ui/src/v2/components/file-tree-v2.css

@@ -14,12 +14,14 @@
   justify-content: flex-start;
   gap: 6px;
   padding-right: 8px;
+  overflow: visible;
   border: none;
   border-radius: 6px;
   background-color: transparent;
   color: var(--v2-text-text-muted);
   text-align: left;
   cursor: pointer;
+  scroll-margin-block: 8px;
   transition:
     background-color 120ms ease,
     color 120ms ease;
@@ -42,6 +44,21 @@
   background-color: var(--v2-overlay-simple-overlay-pressed);
 }
 
+[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] {
+  position: absolute;
+  top: 0;
+  margin-top: -2px;
+  height: calc(100% + 2px);
+  width: 1px;
+  pointer-events: none;
+  background-color: var(--v2-border-border-muted);
+  opacity: 0;
+}
+
+[data-component="file-tree-v2"]:hover [data-slot="file-tree-v2-guide"] {
+  opacity: 0.5;
+}
+
 [data-component="file-tree-v2"] .filetree-icon--mono {
   color: var(--v2-icon-icon-muted);
 }
@@ -108,7 +125,7 @@
 }
 
 [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] {
-  opacity: 0;
+  color: var(--v2-state-fg-info);
 }
 
 [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="added"] {

+ 4 - 0
packages/ui/src/v2/components/icon.tsx

@@ -129,6 +129,10 @@ const icons = {
     viewBox: "0 0 16 16",
     body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
   },
+  "outline-share": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M13.5554 10.4445V13.5556C13.5554 13.5556 12.7599 13.5556 11.7777 13.5556H4.22211C3.23989 13.5556 2.44434 13.5556 2.44434 13.5556V10.4445M4.88878 5.55557L7.99989 2.44446L11.111 5.55557M7.99989 2.44446L7.99989 9.11112" stroke="currentColor"/>`,
+  },
   reset: {
     viewBox: "0 0 20 20",
     body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,

+ 53 - 0
packages/ui/src/v2/components/line-comment-v2.css

@@ -203,3 +203,56 @@
   gap: 8px;
   flex-shrink: 0;
 }
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-list"] {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  max-height: 180px;
+  overflow: auto;
+  margin-top: 8px;
+  padding: 4px;
+  border: 0.5px solid var(--v2-border-border-muted);
+  border-radius: 6px;
+  background: var(--v2-background-bg-layer-02);
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"] {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  min-width: 0;
+  padding: 6px 8px;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  color: var(--v2-text-text-base);
+  text-align: left;
+  cursor: pointer;
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"][data-active] {
+  background: var(--v2-background-bg-layer-03);
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-path"] {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  font-size: 12px;
+  line-height: 16px;
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-dir"] {
+  min-width: 0;
+  color: var(--v2-text-text-muted);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-file"] {
+  color: var(--v2-text-text-base);
+  white-space: nowrap;
+}

+ 151 - 2
packages/ui/src/v2/components/line-comment-v2.tsx

@@ -1,4 +1,6 @@
-import { type ComponentProps, type JSX, Show, onMount, splitProps } from "solid-js"
+import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { FileIcon } from "../../components/file-icon"
+import { useFilteredList } from "../../hooks"
 import { ButtonV2 } from "./button-v2"
 import "./line-comment-v2.css"
 
@@ -53,6 +55,10 @@ export function LineCommentV2(props: LineCommentV2Props) {
   )
 }
 
+export type LineCommentEditorV2Mention = {
+  items: (query: string) => string[] | Promise<string[]>
+}
+
 export interface LineCommentEditorV2Props extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
   /** Visible field label above the textarea (default: “Comment”). */
   heading?: JSX.Element | string
@@ -66,10 +72,22 @@ export interface LineCommentEditorV2Props extends Omit<ComponentProps<"div">, "c
   cancelLabel?: string
   submitLabel?: string
   autofocus?: boolean
+  mention?: LineCommentEditorV2Mention
+}
+
+function pathFilename(path: string) {
+  const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
+  return index === -1 ? path : path.slice(index + 1)
+}
+
+function pathDirectory(path: string) {
+  const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
+  return index === -1 ? "" : path.slice(0, index + 1)
 }
 
 export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
   let textareaRef: HTMLTextAreaElement | undefined
+  const [mentionOpen, setMentionOpen] = createSignal(false)
 
   const [local, rest] = splitProps(props, [
     "heading",
@@ -83,6 +101,7 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
     "cancelLabel",
     "submitLabel",
     "autofocus",
+    "mention",
     "class",
     "classList",
   ])
@@ -90,6 +109,78 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
   const heading = () => local.heading ?? "Comment"
   const canSubmit = () => local.value.trim().length > 0
 
+  const closeMention = () => {
+    setMentionOpen(false)
+    mention.clear()
+  }
+
+  const currentMention = () => {
+    const textarea = textareaRef
+    if (!textarea) return
+    if (!local.mention) return
+    if (textarea.selectionStart !== textarea.selectionEnd) return
+
+    const end = textarea.selectionStart
+    const match = textarea.value.slice(0, end).match(/@(\S*)$/)
+    if (!match) return
+
+    return {
+      query: match[1] ?? "",
+      start: end - match[0].length,
+      end,
+    }
+  }
+
+  function selectMention(item: { path: string } | undefined) {
+    if (!item) return
+
+    const textarea = textareaRef
+    const query = currentMention()
+    if (!textarea || !query) return
+
+    const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
+    const cursor = query.start + item.path.length + 2
+
+    local.onInput(value)
+    closeMention()
+
+    requestAnimationFrame(() => {
+      textarea.focus()
+      textarea.setSelectionRange(cursor, cursor)
+    })
+  }
+
+  const mention = useFilteredList<{ path: string }>({
+    items: async (query) => {
+      if (!local.mention) return []
+      if (!query.trim()) return []
+      const paths = await local.mention.items(query)
+      return paths.map((path) => ({ path }))
+    },
+    key: (item) => item.path,
+    filterKeys: ["path"],
+    skipFilter: () => true,
+    onSelect: selectMention,
+  })
+
+  const syncMention = () => {
+    const item = currentMention()
+    if (!item) {
+      closeMention()
+      return
+    }
+
+    setMentionOpen(true)
+    mention.onInput(item.query)
+  }
+
+  const selectActiveMention = () => {
+    const items = mention.flat()
+    if (items.length === 0) return
+    const active = mention.active()
+    selectMention(items.find((item) => item.path === active) ?? items[0])
+  }
+
   const submit = () => {
     const v = local.value.trim()
     if (!v) return
@@ -122,9 +213,39 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
             rows={local.rows ?? 3}
             placeholder={local.placeholder ?? "Add context for this change"}
             value={local.value}
-            onInput={(e) => local.onInput(e.currentTarget.value)}
+            onInput={(e) => {
+              local.onInput(e.currentTarget.value)
+              syncMention()
+            }}
+            onClick={() => syncMention()}
+            onSelect={() => syncMention()}
             onKeyDown={(e) => {
               e.stopPropagation()
+              if (e.isComposing || e.keyCode === 229) return
+
+              if (mentionOpen()) {
+                if (e.key === "Escape") {
+                  e.preventDefault()
+                  closeMention()
+                  return
+                }
+
+                if (e.key === "Tab") {
+                  if (mention.flat().length === 0) return
+                  e.preventDefault()
+                  selectActiveMention()
+                  return
+                }
+
+                const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
+                const ctrlNav = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")
+                if ((nav || ctrlNav) && mention.flat().length > 0) {
+                  mention.onKeyDown(e)
+                  e.preventDefault()
+                  return
+                }
+              }
+
               if (e.key === "Escape") {
                 e.preventDefault()
                 e.currentTarget.blur()
@@ -137,6 +258,34 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
               }
             }}
           />
+          <Show when={mentionOpen() && mention.flat().length > 0}>
+            <div data-slot="line-comment-v2-mention-list">
+              <For each={mention.flat().slice(0, 10)}>
+                {(item) => {
+                  const directory = item.path.endsWith("/") ? item.path : pathDirectory(item.path)
+                  const name = item.path.endsWith("/") ? "" : pathFilename(item.path)
+                  return (
+                    <button
+                      type="button"
+                      data-slot="line-comment-v2-mention-item"
+                      data-active={mention.active() === item.path ? "" : undefined}
+                      onMouseDown={(event) => event.preventDefault()}
+                      onMouseEnter={() => mention.setActive(item.path)}
+                      onClick={() => selectMention(item)}
+                    >
+                      <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
+                      <div data-slot="line-comment-v2-mention-path">
+                        <span data-slot="line-comment-v2-mention-dir">{directory}</span>
+                        <Show when={name}>
+                          <span data-slot="line-comment-v2-mention-file">{name}</span>
+                        </Show>
+                      </div>
+                    </button>
+                  )
+                }}
+              </For>
+            </div>
+          </Show>
         </div>
         <div data-slot="line-comment-v2-footer">
           <div data-slot="line-comment-v2-footer-meta">{local.selection}</div>

+ 2 - 3
packages/ui/src/v2/components/project-avatar-v2.tsx

@@ -38,7 +38,6 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> {
 
 export function ProjectAvatar(props: ProjectAvatarProps) {
   const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"])
-  const src = split.src
   return (
     <div
       {...rest}
@@ -53,9 +52,9 @@ export function ProjectAvatar(props: ProjectAvatarProps) {
       <div
         data-slot="project-avatar-surface"
         data-variant={split.variant ?? "gray"}
-        data-has-image={src ? "" : undefined}
+        data-has-image={split.src ? "" : undefined}
       >
-        <Show when={src} fallback={first(split.fallback)}>
+        <Show when={split.src} fallback={first(split.fallback)}>
           {(value) => <img src={value()} draggable={false} data-slot="project-avatar-image" />}
         </Show>
       </div>

+ 99 - 0
packages/ui/src/v2/components/split-button-v2.css

@@ -0,0 +1,99 @@
+[data-component="split-button-v2"] {
+  display: inline-flex;
+  align-items: stretch;
+  flex-shrink: 0;
+  height: 28px;
+  border-radius: 6px;
+  background-color: transparent;
+  box-shadow: none;
+  overflow: hidden;
+}
+
+[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) {
+  box-shadow: inset 0 0 0 1px var(--v2-border-border-muted);
+}
+
+[data-component="split-button-v2-action"],
+[data-component="split-button-v2-menu-trigger"] {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  margin: 0;
+  padding: 0;
+  border: none;
+  background: transparent;
+  color: var(--v2-icon-icon-base);
+  cursor: default;
+  outline: none;
+}
+
+[data-component="split-button-v2-action"] {
+  width: 28px;
+  flex-shrink: 0;
+}
+
+[data-component="split-button-v2-action"]:hover:not(:disabled) {
+  background-color: var(--v2-overlay-simple-overlay-hover);
+}
+
+[data-component="split-button-v2-action"]:is(:active, [data-pressed]):not(:disabled) {
+  background-color: var(--v2-overlay-simple-overlay-pressed);
+}
+
+[data-component="split-button-v2-action"]:is(:focus-visible, [data-focus]) {
+  outline: 2px solid var(--v2-border-border-focus);
+  outline-offset: -2px;
+  z-index: 1;
+}
+
+[data-component="split-button-v2-action"]:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+[data-component="split-button-v2-menu-trigger"] {
+  width: 20px;
+  flex-shrink: 0;
+}
+
+[data-component="split-button-v2-menu-trigger"]:is(:hover, [data-expanded]):not(:disabled) {
+  background-color: var(--v2-overlay-simple-overlay-hover);
+}
+
+[data-component="split-button-v2-menu-trigger"]:is(:active, [data-pressed]):not(:disabled) {
+  background-color: var(--v2-overlay-simple-overlay-pressed);
+}
+
+[data-component="split-button-v2-menu-trigger"]:is(:focus-visible, [data-focus]) {
+  outline: 2px solid var(--v2-border-border-focus);
+  outline-offset: -2px;
+  z-index: 1;
+}
+
+[data-component="split-button-v2-menu-trigger"]:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+[data-component="split-button-v2-action"] [data-component="app-icon"] {
+  width: 16px;
+  height: 16px;
+}
+
+[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="app-icon"] {
+  width: 16px;
+  height: 16px;
+  flex-shrink: 0;
+}
+
+[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] {
+  width: 16px;
+  height: 16px;
+  flex-shrink: 0;
+}
+
+[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] [data-slot="icon-svg"] {
+  width: 16px;
+  height: 16px;
+}

+ 48 - 0
packages/ui/src/v2/components/split-button-v2.tsx

@@ -0,0 +1,48 @@
+import { splitProps, type ComponentProps, type ParentProps } from "solid-js"
+import "./split-button-v2.css"
+
+export function SplitButtonV2(props: ParentProps<ComponentProps<"div">>) {
+  const [split, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <div
+      data-component="split-button-v2"
+      classList={{
+        ...split.classList,
+        [split.class ?? ""]: !!split.class,
+      }}
+      {...rest}
+    >
+      {split.children}
+    </div>
+  )
+}
+
+export function SplitButtonV2Action(props: ComponentProps<"button">) {
+  const [split, rest] = splitProps(props, ["class", "classList"])
+  return (
+    <button
+      type="button"
+      data-component="split-button-v2-action"
+      classList={{
+        ...split.classList,
+        [split.class ?? ""]: !!split.class,
+      }}
+      {...rest}
+    />
+  )
+}
+
+export function SplitButtonV2MenuTrigger(props: ComponentProps<"button">) {
+  const [split, rest] = splitProps(props, ["class", "classList"])
+  return (
+    <button
+      type="button"
+      data-component="split-button-v2-menu-trigger"
+      classList={{
+        ...split.classList,
+        [split.class ?? ""]: !!split.class,
+      }}
+      {...rest}
+    />
+  )
+}