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

chore: merge dev into v2 (#36770)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
opencode-agent[bot] 1 месяц назад
Родитель
Сommit
634386fe0f
100 измененных файлов с 3142 добавлено и 858 удалено
  1. 1 1
      .opencode/tool/github-triage.ts
  2. 82 48
      bun.lock
  3. 4 4
      nix/hashes.json
  4. 4 5
      package.json
  5. 150 0
      packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts
  6. 9 1
      packages/app/e2e/regression/review-open-file.spec.ts
  7. 2 2
      packages/app/e2e/regression/review-tab-switch.spec.ts
  8. 1 1
      packages/app/e2e/regression/review-terminal-stacked.spec.ts
  9. 62 0
      packages/app/e2e/regression/session-request-docks.spec.ts
  10. 4 1
      packages/app/e2e/regression/session-timeline-projection.spec.ts
  11. 1 1
      packages/app/package.json
  12. 156 0
      packages/app/src/components/dialog-edit-project-v2.tsx
  13. 38 135
      packages/app/src/components/dialog-edit-project.tsx
  14. 119 0
      packages/app/src/components/edit-project.ts
  15. 36 8
      packages/app/src/components/file-tree-v2-model.test.ts
  16. 30 0
      packages/app/src/components/file-tree-v2-model.ts
  17. 60 29
      packages/app/src/components/file-tree-v2.tsx
  18. 20 3
      packages/app/src/components/prompt-input.tsx
  19. 13 3
      packages/app/src/components/prompt-input/context-items.tsx
  20. 43 0
      packages/app/src/components/prompt-input/image-attachments.css
  21. 128 21
      packages/app/src/components/prompt-input/image-attachments.tsx
  22. 12 4
      packages/app/src/components/prompt-workspace-selector.tsx
  23. 1 0
      packages/app/src/components/session/index.ts
  24. 98 0
      packages/app/src/components/session/open-in-app-v2.tsx
  25. 229 0
      packages/app/src/components/session/open-in-app.tsx
  26. 66 0
      packages/app/src/components/session/session-sortable-tab-v2.tsx
  27. 36 0
      packages/app/src/components/status-popover-indicator.test.ts
  28. 19 0
      packages/app/src/components/status-popover-indicator.ts
  29. 25 43
      packages/app/src/components/status-popover.tsx
  30. 12 4
      packages/app/src/context/layout.tsx
  31. 3 0
      packages/app/src/context/platform.tsx
  32. 1 1
      packages/app/src/context/prompt-state.ts
  33. 1 0
      packages/app/src/context/prompt.tsx
  34. 1 1
      packages/app/src/i18n/en.ts
  35. 24 5
      packages/app/src/pages/home-session-open.test.ts
  36. 3 0
      packages/app/src/pages/home-session-open.ts
  37. 1 0
      packages/app/src/pages/home.tsx
  38. 40 3
      packages/app/src/pages/session.tsx
  39. 329 1
      packages/app/src/pages/session/file-tabs.tsx
  40. 409 157
      packages/app/src/pages/session/session-side-panel.tsx
  41. 6 0
      packages/app/src/pages/session/timeline/message-timeline.tsx
  42. 2 0
      packages/app/src/pages/session/timeline/projection.ts
  43. 4 2
      packages/app/src/pages/session/timeline/rows.ts
  44. 3 5
      packages/app/src/pages/session/v2/review-panel-v2.tsx
  45. 74 89
      packages/app/src/pages/session/v2/session-file-browser-tab.tsx
  46. 43 16
      packages/app/test-browser/solid-virtual.test.ts
  47. 1 1
      packages/codemode/package.json
  48. 1 1
      packages/console/app/package.json
  49. 1 1
      packages/console/core/package.json
  50. 1 1
      packages/console/function/package.json
  51. 1 1
      packages/console/mail/package.json
  52. 1 1
      packages/console/support/package.json
  53. 4 4
      packages/core/package.json
  54. 1 1
      packages/desktop/package.json
  55. 10 0
      packages/desktop/src/main/ipc.ts
  56. 1 0
      packages/desktop/src/preload/index.ts
  57. 1 0
      packages/desktop/src/preload/types.ts
  58. 3 0
      packages/desktop/src/renderer/index.tsx
  59. 1 1
      packages/effect-drizzle-sqlite/package.json
  60. 1 1
      packages/effect-sqlite-node/package.json
  61. 1 1
      packages/enterprise/package.json
  62. 1 1
      packages/function/package.json
  63. 1 1
      packages/http-recorder/package.json
  64. 1 1
      packages/llm/package.json
  65. 4 4
      packages/opencode/package.json
  66. 13 1
      packages/opencode/src/account/account.ts
  67. 3 0
      packages/opencode/src/plugin/openai/codex.ts
  68. 21 12
      packages/opencode/src/provider/provider.ts
  69. 8 6
      packages/opencode/src/provider/transform.ts
  70. 47 0
      packages/opencode/test/account/service.test.ts
  71. 26 7
      packages/opencode/test/plugin/codex.test.ts
  72. 17 5
      packages/opencode/test/provider/provider.test.ts
  73. 73 0
      packages/opencode/test/provider/transform.test.ts
  74. 2 1
      packages/opencode/test/session/llm.test.ts
  75. 1 1
      packages/plugin/package.json
  76. 1 1
      packages/sdk/js/package.json
  77. 1 1
      packages/server/package.json
  78. 1 1
      packages/session-ui/package.json
  79. 11 1
      packages/session-ui/src/components/message-file.test.ts
  80. 21 0
      packages/session-ui/src/components/message-file.ts
  81. 24 2
      packages/session-ui/src/components/message-part.css
  82. 62 21
      packages/session-ui/src/components/message-part.tsx
  83. 58 0
      packages/session-ui/src/v2/components/attachment-card-v2.css
  84. 26 0
      packages/session-ui/src/v2/components/attachment-card-v2.tsx
  85. 27 0
      packages/session-ui/src/v2/components/comment-card-v2.tsx
  86. 8 0
      packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx
  87. 6 1
      packages/session-ui/src/v2/components/session-review-v2.css
  88. 2 3
      packages/session-ui/src/v2/components/session-review-v2.tsx
  89. 1 1
      packages/slack/package.json
  90. 2 1
      packages/stats/app/package.json
  91. 27 41
      packages/stats/app/src/routes/[lab]/[model].tsx
  92. 15 22
      packages/stats/app/src/routes/[lab]/index.tsx
  93. 71 0
      packages/stats/app/src/routes/breadcrumb-select.tsx
  94. 1 1
      packages/stats/app/src/routes/compare/index.tsx
  95. 117 109
      packages/stats/app/src/routes/index.css
  96. 1 1
      packages/stats/core/package.json
  97. 1 1
      packages/stats/server/package.json
  98. 4 0
      packages/storybook/.storybook/mocks/app/context/prompt.ts
  99. 1 1
      packages/tui/package.json
  100. 1 1
      packages/ui/package.json

+ 1 - 1
.opencode/tool/github-triage.ts

@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
 const TEAM = {
 const TEAM = {
   tui: ["kommander", "simonklee"],
   tui: ["kommander", "simonklee"],
   desktop_web: ["Hona", "Brendonovich"],
   desktop_web: ["Hona", "Brendonovich"],
-  core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
+  core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
   inference: ["fwang", "MrMushrooooom", "starptech"],
   inference: ["fwang", "MrMushrooooom", "starptech"],
   windows: ["Hona"],
   windows: ["Hona"],
 } as const
 } as const

+ 82 - 48
bun.lock

@@ -32,7 +32,7 @@
     },
     },
     "packages/app": {
     "packages/app": {
       "name": "@opencode-ai/app",
       "name": "@opencode-ai/app",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@corvu/drawer": "catalog:",
         "@corvu/drawer": "catalog:",
         "@dnd-kit/abstract": "0.5.0",
         "@dnd-kit/abstract": "0.5.0",
@@ -157,7 +157,7 @@
     },
     },
     "packages/codemode": {
     "packages/codemode": {
       "name": "@opencode-ai/codemode",
       "name": "@opencode-ai/codemode",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "acorn": "8.15.0",
         "acorn": "8.15.0",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -171,7 +171,7 @@
     },
     },
     "packages/console/app": {
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
       "name": "@opencode-ai/console-app",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
         "@ibm/plex": "6.4.1",
@@ -207,7 +207,7 @@
     },
     },
     "packages/console/core": {
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
       "name": "@opencode-ai/console-core",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
         "@jsx-email/render": "1.1.1",
@@ -234,7 +234,7 @@
     },
     },
     "packages/console/function": {
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
       "name": "@opencode-ai/console-function",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.82",
         "@ai-sdk/anthropic": "3.0.82",
         "@ai-sdk/openai": "3.0.48",
         "@ai-sdk/openai": "3.0.48",
@@ -256,7 +256,7 @@
     },
     },
     "packages/console/mail": {
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
       "name": "@opencode-ai/console-mail",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
         "@jsx-email/cli": "1.4.3",
@@ -280,7 +280,7 @@
     },
     },
     "packages/console/support": {
     "packages/console/support": {
       "name": "@opencode-ai/console-support",
       "name": "@opencode-ai/console-support",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@opencode-ai/console-core": "workspace:*",
         "@opencode-ai/console-core": "workspace:*",
@@ -300,7 +300,7 @@
     },
     },
     "packages/core": {
     "packages/core": {
       "name": "@opencode-ai/core",
       "name": "@opencode-ai/core",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -308,7 +308,7 @@
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/amazon-bedrock": "4.0.112",
         "@ai-sdk/amazon-bedrock": "4.0.112",
         "@ai-sdk/anthropic": "3.0.82",
         "@ai-sdk/anthropic": "3.0.82",
-        "@ai-sdk/azure": "3.0.49",
+        "@ai-sdk/azure": "3.0.88",
         "@ai-sdk/cerebras": "2.0.41",
         "@ai-sdk/cerebras": "2.0.41",
         "@ai-sdk/cohere": "3.0.27",
         "@ai-sdk/cohere": "3.0.27",
         "@ai-sdk/deepinfra": "2.0.41",
         "@ai-sdk/deepinfra": "2.0.41",
@@ -317,7 +317,7 @@
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/groq": "3.0.31",
         "@ai-sdk/groq": "3.0.31",
         "@ai-sdk/mistral": "3.0.27",
         "@ai-sdk/mistral": "3.0.27",
-        "@ai-sdk/openai": "3.0.53",
+        "@ai-sdk/openai": "3.0.84",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/perplexity": "3.0.26",
         "@ai-sdk/perplexity": "3.0.26",
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/provider": "3.0.8",
@@ -354,7 +354,7 @@
         "drizzle-orm": "catalog:",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.10.0",
+        "gitlab-ai-provider": "6.11.1",
         "glob": "13.0.5",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
         "gray-matter": "4.0.3",
@@ -396,7 +396,7 @@
     },
     },
     "packages/desktop": {
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
       "name": "@opencode-ai/desktop",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@zip.js/zip.js": "2.7.62",
         "@zip.js/zip.js": "2.7.62",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -456,7 +456,7 @@
     },
     },
     "packages/effect-drizzle-sqlite": {
     "packages/effect-drizzle-sqlite": {
       "name": "@opencode-ai/effect-drizzle-sqlite",
       "name": "@opencode-ai/effect-drizzle-sqlite",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "drizzle-orm": "catalog:",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -470,7 +470,7 @@
     },
     },
     "packages/effect-sqlite-node": {
     "packages/effect-sqlite-node": {
       "name": "@opencode-ai/effect-sqlite-node",
       "name": "@opencode-ai/effect-sqlite-node",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "effect": "catalog:",
         "effect": "catalog:",
       },
       },
@@ -482,7 +482,7 @@
     },
     },
     "packages/enterprise": {
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
       "name": "@opencode-ai/enterprise",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@hono/standard-validator": "catalog:",
         "@hono/standard-validator": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
@@ -514,7 +514,7 @@
     },
     },
     "packages/function": {
     "packages/function": {
       "name": "@opencode-ai/function",
       "name": "@opencode-ai/function",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
         "@octokit/rest": "catalog:",
@@ -530,7 +530,7 @@
     },
     },
     "packages/http-recorder": {
     "packages/http-recorder": {
       "name": "@opencode-ai/http-recorder",
       "name": "@opencode-ai/http-recorder",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@effect/platform-node-shared": "4.0.0-beta.83",
         "@effect/platform-node-shared": "4.0.0-beta.83",
       },
       },
@@ -562,7 +562,7 @@
     },
     },
     "packages/llm": {
     "packages/llm": {
       "name": "@opencode-ai/llm",
       "name": "@opencode-ai/llm",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@smithy/eventstream-codec": "4.2.14",
         "@smithy/eventstream-codec": "4.2.14",
@@ -581,7 +581,7 @@
     },
     },
     "packages/opencode": {
     "packages/opencode": {
       "name": "opencode",
       "name": "opencode",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -592,7 +592,7 @@
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/amazon-bedrock": "4.0.112",
         "@ai-sdk/amazon-bedrock": "4.0.112",
         "@ai-sdk/anthropic": "3.0.82",
         "@ai-sdk/anthropic": "3.0.82",
-        "@ai-sdk/azure": "3.0.49",
+        "@ai-sdk/azure": "3.0.88",
         "@ai-sdk/cerebras": "2.0.60",
         "@ai-sdk/cerebras": "2.0.60",
         "@ai-sdk/cohere": "3.0.27",
         "@ai-sdk/cohere": "3.0.27",
         "@ai-sdk/deepinfra": "2.0.41",
         "@ai-sdk/deepinfra": "2.0.41",
@@ -601,7 +601,7 @@
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/groq": "3.0.31",
         "@ai-sdk/groq": "3.0.31",
         "@ai-sdk/mistral": "3.0.27",
         "@ai-sdk/mistral": "3.0.27",
-        "@ai-sdk/openai": "3.0.53",
+        "@ai-sdk/openai": "3.0.84",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/perplexity": "3.0.26",
         "@ai-sdk/perplexity": "3.0.26",
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/provider": "3.0.8",
@@ -656,7 +656,7 @@
         "drizzle-orm": "catalog:",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.10.0",
+        "gitlab-ai-provider": "6.11.1",
         "glob": "13.0.5",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
         "gray-matter": "4.0.3",
@@ -714,7 +714,7 @@
     },
     },
     "packages/plugin": {
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
       "name": "@opencode-ai/plugin",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/provider": "3.0.8",
         "@opencode-ai/client": "workspace:*",
         "@opencode-ai/client": "workspace:*",
@@ -801,7 +801,7 @@
     },
     },
     "packages/sdk/js": {
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "name": "@opencode-ai/sdk",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "cross-spawn": "catalog:",
         "cross-spawn": "catalog:",
       },
       },
@@ -816,7 +816,7 @@
     },
     },
     "packages/server": {
     "packages/server": {
       "name": "@opencode-ai/server",
       "name": "@opencode-ai/server",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@effect/platform-node": "catalog:",
         "@effect/platform-node": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
@@ -833,7 +833,7 @@
     },
     },
     "packages/session-ui": {
     "packages/session-ui": {
       "name": "@opencode-ai/session-ui",
       "name": "@opencode-ai/session-ui",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
@@ -894,7 +894,7 @@
     },
     },
     "packages/slack": {
     "packages/slack": {
       "name": "@opencode-ai/slack",
       "name": "@opencode-ai/slack",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
         "@slack/bolt": "^3.17.1",
@@ -907,9 +907,10 @@
     },
     },
     "packages/stats/app": {
     "packages/stats/app": {
       "name": "@opencode-ai/stats-app",
       "name": "@opencode-ai/stats-app",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@ibm/plex": "6.4.1",
         "@ibm/plex": "6.4.1",
+        "@kobalte/core": "catalog:",
         "@opencode-ai/stats-core": "workspace:*",
         "@opencode-ai/stats-core": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@solidjs/meta": "catalog:",
         "@solidjs/meta": "catalog:",
@@ -940,7 +941,7 @@
     },
     },
     "packages/stats/core": {
     "packages/stats/core": {
       "name": "@opencode-ai/stats-core",
       "name": "@opencode-ai/stats-core",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-athena": "3.933.0",
         "@aws-sdk/client-athena": "3.933.0",
         "@planetscale/database": "1.19.0",
         "@planetscale/database": "1.19.0",
@@ -959,7 +960,7 @@
     },
     },
     "packages/stats/server": {
     "packages/stats/server": {
       "name": "@opencode-ai/stats-server",
       "name": "@opencode-ai/stats-server",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-firehose": "3.933.0",
         "@aws-sdk/client-firehose": "3.933.0",
         "@effect/platform-node": "catalog:",
         "@effect/platform-node": "catalog:",
@@ -1000,7 +1001,7 @@
     },
     },
     "packages/tui": {
     "packages/tui": {
       "name": "@opencode-ai/tui",
       "name": "@opencode-ai/tui",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/client": "workspace:*",
         "@opencode-ai/client": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
@@ -1030,7 +1031,7 @@
     },
     },
     "packages/ui": {
     "packages/ui": {
       "name": "@opencode-ai/ui",
       "name": "@opencode-ai/ui",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@pierre/diffs": "catalog:",
         "@pierre/diffs": "catalog:",
@@ -1081,7 +1082,7 @@
     },
     },
     "packages/web": {
     "packages/web": {
       "name": "@opencode-ai/web",
       "name": "@opencode-ai/web",
-      "version": "1.17.18",
+      "version": "1.17.20",
       "dependencies": {
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
         "@astrojs/markdown-remark": "6.3.1",
@@ -1152,6 +1153,7 @@
   ],
   ],
   "patchedDependencies": {
   "patchedDependencies": {
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
+    "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
     "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
     "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
     "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
     "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1159,10 +1161,8 @@
     "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
     "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
     "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
     "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
-    "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
-    "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
     "pacote@21.5.0": "patches/pacote@21.5.0.patch",
     "pacote@21.5.0": "patches/pacote@21.5.0.patch",
   },
   },
   "overrides": {
   "overrides": {
@@ -1198,7 +1198,7 @@
     "@solidjs/router": "0.15.4",
     "@solidjs/router": "0.15.4",
     "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
     "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
     "@tailwindcss/vite": "4.1.11",
     "@tailwindcss/vite": "4.1.11",
-    "@tanstack/solid-virtual": "3.13.28",
+    "@tanstack/solid-virtual": "3.13.32",
     "@tsconfig/bun": "1.0.9",
     "@tsconfig/bun": "1.0.9",
     "@tsconfig/node22": "22.0.2",
     "@tsconfig/node22": "22.0.2",
     "@types/bun": "1.3.13",
     "@types/bun": "1.3.13",
@@ -1218,7 +1218,7 @@
     "hono": "4.10.7",
     "hono": "4.10.7",
     "hono-openapi": "1.1.2",
     "hono-openapi": "1.1.2",
     "luxon": "3.6.1",
     "luxon": "3.6.1",
-    "marked": "17.0.1",
+    "marked": "17.0.6",
     "marked-shiki": "1.2.1",
     "marked-shiki": "1.2.1",
     "opentui-spinner": "0.0.7",
     "opentui-spinner": "0.0.7",
     "remeda": "2.26.0",
     "remeda": "2.26.0",
@@ -1260,7 +1260,7 @@
 
 
     "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
     "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
 
 
-    "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
+    "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
 
 
     "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
     "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
 
 
@@ -1270,7 +1270,7 @@
 
 
     "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
     "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
 
 
-    "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
+    "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
 
 
     "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
     "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
 
 
@@ -3122,7 +3122,7 @@
 
 
     "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
     "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
 
 
-    "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
+    "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
 
 
     "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="],
     "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="],
 
 
@@ -3136,7 +3136,7 @@
 
 
     "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
     "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
 
 
-    "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
+    "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
 
 
     "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
     "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
 
 
@@ -4344,7 +4344,7 @@
 
 
     "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
     "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
 
-    "gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
+    "gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
 
 
     "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
     "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
 
@@ -4854,7 +4854,7 @@
 
 
     "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
     "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
 
 
-    "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
+    "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
 
 
     "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
     "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
 
 
@@ -6340,7 +6340,11 @@
 
 
     "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
     "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
 
 
-    "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+    "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
+
+    "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+
+    "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
 
 
     "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
     "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
 
@@ -6352,9 +6356,9 @@
 
 
     "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
     "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
 
-    "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
+    "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
 
 
-    "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+    "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
 
 
     "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
     "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
 
 
@@ -6844,7 +6848,7 @@
 
 
     "@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
     "@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
 
 
-    "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
+    "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
 
 
     "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
     "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
 
@@ -6884,6 +6888,8 @@
 
 
     "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
     "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
 
 
+    "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
+
     "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
     "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
 
 
     "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
     "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -7230,6 +7236,10 @@
 
 
     "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
     "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
 
 
+    "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
+
+    "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
+
     "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
     "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
 
 
     "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
     "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -7534,7 +7544,7 @@
 
 
     "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
     "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
 
 
-    "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
+    "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
 
 
     "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
     "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
 
@@ -8226,6 +8236,10 @@
 
 
     "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
     "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
 
 
+    "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+
+    "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
+
     "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
     "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
 
 
     "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
     "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
@@ -8470,6 +8484,14 @@
 
 
     "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
     "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
 
 
+    "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
+
+    "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+
+    "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
+
+    "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+
     "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
     "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
 
     "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
     "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8782,6 +8804,10 @@
 
 
     "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
     "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
 
 
+    "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+
+    "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
+
     "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
     "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
 
 
     "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
     "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -9116,6 +9142,8 @@
 
 
     "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
     "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
 
 
+    "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
     "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
 
 
     "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
     "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
@@ -9302,6 +9330,10 @@
 
 
     "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
     "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
 
+    "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+    "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
     "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
 
 
     "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
     "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -9402,6 +9434,8 @@
 
 
     "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
     "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
 
+    "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
     "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
 
 
     "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
     "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
 {
   "nodeModules": {
   "nodeModules": {
-    "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
-    "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
-    "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
-    "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
+    "x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=",
+    "aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=",
+    "aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=",
+    "x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw="
   }
   }
 }
 }

+ 4 - 5
package.json

@@ -49,7 +49,7 @@
       "@opentui/core": "0.4.3",
       "@opentui/core": "0.4.3",
       "@opentui/keymap": "0.4.3",
       "@opentui/keymap": "0.4.3",
       "@opentui/solid": "0.4.3",
       "@opentui/solid": "0.4.3",
-      "@tanstack/solid-virtual": "3.13.28",
+      "@tanstack/solid-virtual": "3.13.32",
       "@shikijs/stream": "4.2.0",
       "@shikijs/stream": "4.2.0",
       "ulid": "3.0.1",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@kobalte/core": "0.13.11",
@@ -76,7 +76,7 @@
       "hono-openapi": "1.1.2",
       "hono-openapi": "1.1.2",
       "fuzzysort": "3.1.0",
       "fuzzysort": "3.1.0",
       "luxon": "3.6.1",
       "luxon": "3.6.1",
-      "marked": "17.0.1",
+      "marked": "17.0.6",
       "marked-shiki": "1.2.1",
       "marked-shiki": "1.2.1",
       "remend": "1.3.0",
       "remend": "1.3.0",
       "@playwright/test": "1.59.1",
       "@playwright/test": "1.59.1",
@@ -161,10 +161,9 @@
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "pacote@21.5.0": "patches/pacote@21.5.0.patch",
     "pacote@21.5.0": "patches/pacote@21.5.0.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
-    "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
     "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
     "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
-    "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
-    "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
+    "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
+    "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
   }
   }
 }
 }

+ 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 panel = page.locator("#review-panel")
   const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
   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" })
   const contextButton = page.getByRole("button", { name: "View context usage" })
   await contextButton.click()
   await contextButton.click()
   await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
   await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
   await panel.getByRole("button", { name: "Open file" }).click()
   await panel.getByRole("button", { name: "Open file" }).click()
   await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
   await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
+  await expect(sidebarToggle).toBeDisabled()
   await expect(sidebar).toBeVisible()
   await expect(sidebar).toBeVisible()
   await contextButton.click()
   await contextButton.click()
   await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
   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()
   await panel.getByRole("button", { name: "Open file" }).click()
   const filter = panel.getByRole("combobox", { name: "Filter files" })
   const filter = panel.getByRole("combobox", { name: "Filter files" })
   await expect(filter).toBeFocused()
   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 panel.getByRole("button", { name: "README.md" }).click()
   await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
   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(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
   await expect(sidebar).toHaveCount(0)
   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 expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
   await filter.press("Enter")
   await filter.press("Enter")
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
   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()
   await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
   expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
   expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
 
 
   await panel.getByRole("button", { name: "Open file" }).click()
   await panel.getByRole("button", { name: "Open file" }).click()
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
   await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
   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 page.keyboard.press("Control+w")
   await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
   await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
   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 expectSessionTitle(page, titleA)
 
 
   await page.getByRole("button", { name: "Toggle review" }).click()
   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(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
   await expect(reviewTabPanel).toHaveAttribute("id", "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"]')
   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 expectTree(page, 8, "git-0.ts")
 
 
   await selectMode(page, "Git changes", "Branch changes")
   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 page.keyboard.press("Control+Backquote")
   await expect(page.locator("#terminal-panel")).toBeVisible()
   await expect(page.locator("#terminal-panel")).toBeVisible()
   await expectTree(page, 2_773, "action.yml")
   await expectTree(page, 2_773, "action.yml")

+ 62 - 0
packages/app/e2e/regression/session-request-docks.spec.ts

@@ -1,6 +1,7 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { expect, test, type Page } from "@playwright/test"
 import { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { installSseTransport } from "../utils/sse-transport"
 import { expectSessionTitle } from "../utils/waits"
 import { expectSessionTitle } from "../utils/waits"
 
 
 const directory = "C:/OpenCode/RequestDocks"
 const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
   expect(request.postDataJSON()).toEqual({ response: "once" })
   expect(request.postDataJSON()).toEqual({ response: "once" })
 })
 })
 
 
+test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
+  const transport = await installSseTransport(page, {
+    server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
+    retry: 20,
+  })
+  await mockServer(page, { questions: [] })
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await transport.waitForConnection()
+  await expectSessionTitle(page, title)
+
+  const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
+  const draft = "keep the caret at the end"
+  await editor.fill(draft)
+  await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
+  for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
+  const cursor = draft.length - 4
+  await expect
+    .poll(() =>
+      editor.evaluate((element) => {
+        const selection = window.getSelection()
+        if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
+        const range = selection.getRangeAt(0).cloneRange()
+        range.selectNodeContents(element)
+        range.setEnd(selection.anchorNode!, selection.anchorOffset)
+        return range.toString().length
+      }),
+    )
+    .toBe(cursor)
+  await transport.send({
+    directory,
+    payload: {
+      type: "question.asked",
+      properties: {
+        id: "question-caret",
+        sessionID,
+        questions: [
+          {
+            header: "Continue",
+            question: "Continue?",
+            options: [{ label: "Yes", description: "Continue the session" }],
+          },
+        ],
+        tool: { messageID: "message-caret", callID: "call-caret" },
+      },
+    },
+  })
+  const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
+  await expect(question).toBeVisible()
+  await expect(editor).toHaveCount(0)
+
+  await transport.send({
+    directory,
+    payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
+  })
+  await expect(question).toHaveCount(0)
+  await expect(editor).toBeVisible()
+  await page.keyboard.press("x")
+
+  await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
+})
+
 async function mockServer(
 async function mockServer(
   page: Page,
   page: Page,
   requests: {
   requests: {

+ 4 - 1
packages/app/e2e/regression/session-timeline-projection.spec.ts

@@ -150,7 +150,10 @@ test.describe("session timeline projection", () => {
       parentID: "msg_2000_diff_next_user",
       parentID: "msg_2000_diff_next_user",
       created: 1700000011000,
       created: 1700000011000,
     })
     })
-    await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
+    await setupTimeline(page, {
+      messages: [user, assistantMessage(), nextUser, nextAssistant],
+      settings: { newLayoutDesigns: false },
+    })
     const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
     const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
     await scroller.evaluate((element) => (element.scrollTop = 0))
     await scroller.evaluate((element) => (element.scrollTop = 0))
 
 

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/app",
   "name": "@opencode-ai/app",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "description": "",
   "description": "",
   "type": "module",
   "type": "module",
   "exports": {
   "exports": {

+ 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 { Button } from "@opencode-ai/ui/button"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
-import { useMutation } from "@tanstack/solid-query"
 import { Icon } from "@opencode-ai/ui/icon"
 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 { type LocalProject, getAvatarColors } from "@/context/layout"
-import { getFilename } from "@opencode-ai/core/util/path"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { getProjectAvatarSource } from "@/pages/layout/helpers"
 import { getProjectAvatarSource } from "@/pages/layout/helpers"
 import { ServerConnection } from "@/context/server"
 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
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 
 
 export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
 export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
-  const dialog = useDialog()
-  const global = useGlobal()
   const language = useLanguage()
   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 (
   return (
     <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
     <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">
         <div class="flex flex-col gap-4">
           <TextField
           <TextField
             autofocus
             autofocus
             type="text"
             type="text"
             label={language.t("dialog.project.edit.name")}
             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">
           <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="flex gap-3 items-start">
               <div
               <div
                 class="relative"
                 class="relative"
-                onMouseEnter={() => setStore("iconHover", true)}
-                onMouseLeave={() => setStore("iconHover", false)}
+                onMouseEnter={() => model.setStore("iconHover", true)}
+                onMouseLeave={() => model.setStore("iconHover", false)}
               >
               >
                 <div
                 <div
                   class="relative size-16 rounded-md transition-colors cursor-pointer"
                   class="relative size-16 rounded-md transition-colors cursor-pointer"
                   classList={{
                   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
                   <Show
                     when={getProjectAvatarSource(props.project.id, {
                     when={getProjectAvatarSource(props.project.id, {
-                      color: store.color,
+                      color: model.store.color,
                       url: props.project.icon?.url,
                       url: props.project.icon?.url,
-                      override: store.iconOverride,
+                      override: model.store.iconOverride,
                     })}
                     })}
                     fallback={
                     fallback={
                       <div class="size-full flex items-center justify-center">
                       <div class="size-full flex items-center justify-center">
                         <Avatar
                         <Avatar
-                          fallback={store.name || defaultName()}
-                          {...getAvatarColors(store.color)}
+                          fallback={model.store.name || model.defaultName()}
+                          {...getAvatarColors(model.store.color)}
                           class="size-full text-[32px]"
                           class="size-full text-[32px]"
                         />
                         />
                       </div>
                       </div>
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                 <div
                 <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"
                   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={{
                   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" />
                   <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
                 <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"
                   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={{
                   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" />
                   <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
               <input
                 id="icon-upload"
                 id="icon-upload"
                 ref={(el) => {
                 ref={(el) => {
-                  iconInput = el
+                  model.setIconInput(el)
                 }}
                 }}
                 type="file"
                 type="file"
                 accept="image/*"
                 accept="image/*"
                 class="hidden"
                 class="hidden"
-                onChange={handleInputChange}
+                onChange={model.inputChange}
               />
               />
               <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
               <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>
                 <span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             </div>
             </div>
           </div>
           </div>
 
 
-          <Show when={!store.iconOverride}>
+          <Show when={!model.store.iconOverride}>
             <div class="flex flex-col gap-2">
             <div class="flex flex-col gap-2">
               <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
               <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
               <div class="flex gap-1.5">
               <div class="flex gap-1.5">
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                     <button
                     <button
                       type="button"
                       type="button"
                       aria-label={language.t("dialog.project.edit.color.select", { color })}
                       aria-label={language.t("dialog.project.edit.color.select", { color })}
-                      aria-pressed={store.color === color}
+                      aria-pressed={model.store.color === color}
                       classList={{
                       classList={{
                         "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
                         "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":
                         "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":
                         "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
-                          store.color !== color,
+                          model.store.color !== color,
                       }}
                       }}
                       onClick={() => {
                       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
                       <Avatar
-                        fallback={store.name || defaultName()}
+                        fallback={model.store.name || model.defaultName()}
                         {...getAvatarColors(color)}
                         {...getAvatarColors(color)}
                         class="size-full rounded"
                         class="size-full rounded"
                       />
                       />
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             label={language.t("dialog.project.edit.worktree.startup")}
             label={language.t("dialog.project.edit.worktree.startup")}
             description={language.t("dialog.project.edit.worktree.startup.description")}
             description={language.t("dialog.project.edit.worktree.startup.description")}
             placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
             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}
             spellcheck={false}
             class="max-h-14 w-full overflow-y-auto font-mono text-xs"
             class="max-h-14 w-full overflow-y-auto font-mono text-xs"
           />
           />
         </div>
         </div>
 
 
         <div class="flex justify-end gap-2">
         <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")}
             {language.t("common.cancel")}
           </Button>
           </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>
           </Button>
         </div>
         </div>
       </form>
       </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 { 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"])
     const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
 
 
     expect(model.total).toBe(8)
     expect(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"])
     const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
 
 
     expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
     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 model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
     const rows = flattenFileTreeV2(model, () => true)
     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.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")
     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])
     const model = buildFileTreeV2Model([file])
 
 
     expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
     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
   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 { Icon } from "@opencode-ai/ui/v2/icon"
 import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
 import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
 import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
 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"
 import { virtualScrollElement } from "@/components/virtual-scroll-element"
 
 
 export type { Kind } from "@/components/file-tree"
 export type { Kind } from "@/components/file-tree"
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
 export const kindLabel = (kind: Kind) => {
 export const kindLabel = (kind: Kind) => {
   if (kind === "add") return "A"
   if (kind === "add") return "A"
   if (kind === "del") return "D"
   if (kind === "del") return "D"
-  return ""
+  return "M"
 }
 }
 
 
 export const kindChange = (kind: Kind) => {
 export const kindChange = (kind: Kind) => {
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
     "class",
     "class",
     "classList",
     "classList",
   ])
   ])
-  const kind = () => local.kinds?.get(local.node.path)
+  const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
 
 
   return (
   return (
     <Dynamic
     <Dynamic
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
 function GuideLines(props: { level: number }) {
 function GuideLines(props: { level: number }) {
   return (
   return (
     <For each={Array.from({ length: props.level })}>
     <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>
     </For>
   )
   )
 }
 }
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
   kinds?: ReadonlyMap<string, Kind>
   kinds?: ReadonlyMap<string, Kind>
   draggable?: boolean
   draggable?: boolean
   onFileClick?: (file: FileNode) => void
   onFileClick?: (file: FileNode) => void
+  onFileDoubleClick?: (file: FileNode) => void
 }) {
 }) {
   const file = useFile()
   const file = useFile()
+  const live = () => props.allowed === undefined
   const draggable = () => props.draggable ?? true
   const draggable = () => props.draggable ?? true
   const active = () => normalizeFileTreeV2Path(props.active ?? "")
   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 [root, setRoot] = createSignal<HTMLDivElement>()
   const [focused, setFocused] = createSignal<string>()
   const [focused, setFocused] = createSignal<string>()
   const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
   const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
       return [...indexes, index].sort((a, b) => a - b)
       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(() => {
   createEffect(() => {
     const path = active()
     const path = active()
-    if (!path) return
+    if (!path) {
+      scrolledActive = undefined
+      return
+    }
     const index = rows().findIndex((row) => row.node.path === path)
     const index = rows().findIndex((row) => row.node.path === path)
     if (index < 0) return
     if (index < 0) return
+    if (scrolledActive === path) return
+    scrolledActive = path
     queueMicrotask(() => {
     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 rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
   const virtualItemByKey = createMemo(
   const virtualItemByKey = createMemo(
     () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
     () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
     <div
     <div
       ref={setRoot}
       ref={setRoot}
       data-component="file-tree-v2"
       data-component="file-tree-v2"
-      data-total-rows={model().total}
+      data-total-rows={live() ? rows().length : model()!.total}
       class="group/file-tree-v2"
       class="group/file-tree-v2"
       style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
       style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
     >
     >
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
                           class="relative"
                           class="relative"
                           onFocus={() => setFocused(row().node.path)}
                           onFocus={() => setFocused(row().node.path)}
                           onBlur={() => setFocused(undefined)}
                           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} />
                           <GuideLines level={row().level} />
                           <Show when={row().level > 0}>
                           <Show when={row().level > 0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
                         class="relative"
                         class="relative"
                         onFocus={() => setFocused(row().node.path)}
                         onFocus={() => setFocused(row().node.path)}
                         onBlur={() => setFocused(undefined)}
                         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} />
                         <GuideLines level={row().level} />
                         <div
                         <div
                           data-slot="file-tree-v2-chevron"
                           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"
                           class="size-4 flex items-center justify-center"
                         >
                         >
                           <Icon name="chevron-down" />
                           <Icon name="chevron-down" />

+ 20 - 3
packages/app/src/components/prompt-input.tsx

@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
 import {
 import {
   ContentPart,
   ContentPart,
   DEFAULT_PROMPT,
   DEFAULT_PROMPT,
+  isCommentItem,
   isPromptEqual,
   isPromptEqual,
   Prompt,
   Prompt,
   usePrompt,
   usePrompt,
@@ -632,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
 
 
   const handleBlur = () => {
   const handleBlur = () => {
-    savedCursor = currentCursor()
+    const cursor = currentCursor()
+    savedCursor = cursor
+    if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
     closePopover()
     closePopover()
     setComposing(false)
     setComposing(false)
   }
   }
@@ -1626,7 +1629,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 )}
                 )}
               />
               />
               <PromptContextItems
               <PromptContextItems
-                items={contextItems()}
+                items={contextItems().filter((item) => !isCommentItem(item))}
                 active={(item) => {
                 active={(item) => {
                   const active = comments.active()
                   const active = comments.active()
                   return !!item.commentID && item.commentID === active?.id && item.path === active?.file
                   return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1636,6 +1639,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   if (item.commentID) comments.remove(item.path, item.commentID)
                   if (item.commentID) comments.remove(item.path, item.commentID)
                   prompt.context.remove(item.key)
                   prompt.context.remove(item.key)
                 }}
                 }}
+                newLayoutDesigns={props.controls.newLayoutDesigns}
                 t={(key) => language.t(key as Parameters<typeof language.t>[0])}
                 t={(key) => language.t(key as Parameters<typeof language.t>[0])}
               />
               />
               <PromptImageAttachments
               <PromptImageAttachments
@@ -1645,6 +1649,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 }
                 }
                 onRemove={removeAttachment}
                 onRemove={removeAttachment}
                 removeLabel={language.t("prompt.attachment.remove")}
                 removeLabel={language.t("prompt.attachment.remove")}
+                newLayoutDesigns={props.controls.newLayoutDesigns}
+                comments={contextItems().filter(isCommentItem)}
+                commentActive={(item) => {
+                  const active = comments.active()
+                  return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+                }}
+                onOpenComment={openComment}
+                onRemoveComment={(item) => {
+                  if (item.commentID) comments.remove(item.path, item.commentID)
+                  prompt.context.remove(item.key)
+                }}
               />
               />
               <div
               <div
                 class="relative min-h-[52px]"
                 class="relative min-h-[52px]"
@@ -1852,6 +1867,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 if (item.commentID) comments.remove(item.path, item.commentID)
                 if (item.commentID) comments.remove(item.path, item.commentID)
                 prompt.context.remove(item.key)
                 prompt.context.remove(item.key)
               }}
               }}
+              newLayoutDesigns={props.controls.newLayoutDesigns}
               t={(key) => language.t(key as Parameters<typeof language.t>[0])}
               t={(key) => language.t(key as Parameters<typeof language.t>[0])}
             />
             />
             <PromptImageAttachments
             <PromptImageAttachments
@@ -1861,6 +1877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               }
               }
               onRemove={removeAttachment}
               onRemove={removeAttachment}
               removeLabel={language.t("prompt.attachment.remove")}
               removeLabel={language.t("prompt.attachment.remove")}
+              newLayoutDesigns={props.controls.newLayoutDesigns}
             />
             />
             <div
             <div
               class="relative"
               class="relative"
@@ -2341,7 +2358,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
           />
           />
         )}
         )}
       </Show>
       </Show>
-      <span class="truncate">{props.state.modelName}</span>
+      <span class="truncate leading-4">{props.state.modelName}</span>
       <span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
       <span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
         <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
         <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
       </span>
       </span>

+ 13 - 3
packages/app/src/components/prompt-input/context-items.tsx

@@ -1,6 +1,8 @@
 import { Component, For, Show } from "solid-js"
 import { Component, For, Show } from "solid-js"
+import { Dynamic } from "solid-js/web"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
 import type { ContextItem } from "@/context/prompt"
 import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
   active: (item: PromptContextItem) => boolean
   active: (item: PromptContextItem) => boolean
   openComment: (item: PromptContextItem) => void
   openComment: (item: PromptContextItem) => void
   remove: (item: PromptContextItem) => void
   remove: (item: PromptContextItem) => void
+  newLayoutDesigns: boolean
   t: (key: string) => string
   t: (key: string) => string
 }
 }
 
 
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
             const selected = props.active(item)
             const selected = props.active(item)
 
 
             return (
             return (
-              <TooltipV2
+              <Dynamic
+                component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
                 value={
                 value={
                   <span class="flex max-w-[300px]">
                   <span class="flex max-w-[300px]">
-                    <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+                    <span
+                      classList={{
+                        "truncate-start [unicode-bidi:plaintext] min-w-0": true,
+                        "text-v2-text-text-muted": props.newLayoutDesigns,
+                        "text-text-invert-base": !props.newLayoutDesigns,
+                      }}
+                    >
                       {directory}
                       {directory}
                     </span>
                     </span>
                     <span class="shrink-0">{filename}</span>
                     <span class="shrink-0">{filename}</span>
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
                     {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
                     {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
                   </Show>
                   </Show>
                 </div>
                 </div>
-              </TooltipV2>
+              </Dynamic>
             )
             )
           }}
           }}
         </For>
         </For>

+ 43 - 0
packages/app/src/components/prompt-input/image-attachments.css

@@ -0,0 +1,43 @@
+@keyframes prompt-attachments-fade-left {
+  from {
+    visibility: hidden;
+  }
+  to {
+    visibility: visible;
+  }
+}
+
+@keyframes prompt-attachments-fade-right {
+  from {
+    visibility: visible;
+  }
+  to {
+    visibility: hidden;
+  }
+}
+
+[data-slot="prompt-attachments"] {
+  timeline-scope: --prompt-attachments-scroll;
+
+  [data-slot^="prompt-attachments-fade-"] {
+    visibility: hidden;
+  }
+}
+
+@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
+  [data-slot="prompt-attachments-scroll"] {
+    scroll-timeline: --prompt-attachments-scroll x;
+  }
+
+  [data-slot="prompt-attachments-fade-left"] {
+    animation: prompt-attachments-fade-left linear both;
+    animation-timeline: --prompt-attachments-scroll;
+    animation-range: 0 0.1px;
+  }
+
+  [data-slot="prompt-attachments-fade-right"] {
+    animation: prompt-attachments-fade-right linear both;
+    animation-timeline: --prompt-attachments-scroll;
+    animation-range: calc(100% - 1.1px) calc(100% - 1px);
+  }
+}

+ 128 - 21
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -1,60 +1,167 @@
 import { Component, For, Show } from "solid-js"
 import { Component, For, Show } from "solid-js"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import type { ImageAttachmentPart } from "@/context/prompt"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
+import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
+import { typeLabel } from "@opencode-ai/session-ui/message-file"
+import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
+import "./image-attachments.css"
+
+type PromptCommentItem = ContextItem & { key: string }
 
 
 type PromptImageAttachmentsProps = {
 type PromptImageAttachmentsProps = {
   attachments: ImageAttachmentPart[]
   attachments: ImageAttachmentPart[]
   onOpen: (attachment: ImageAttachmentPart) => void
   onOpen: (attachment: ImageAttachmentPart) => void
   onRemove: (id: string) => void
   onRemove: (id: string) => void
   removeLabel: string
   removeLabel: string
+  newLayoutDesigns: boolean
+  comments?: PromptCommentItem[]
+  commentActive?: (item: PromptCommentItem) => boolean
+  onOpenComment?: (item: PromptCommentItem) => void
+  onRemoveComment?: (item: PromptCommentItem) => void
 }
 }
 
 
 const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
 const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
 const imageClass =
 const imageClass =
   "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
   "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
+// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
+const imageHairlineClassV2 =
+  "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
 const removeClass =
 const removeClass =
   "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
   "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+const removeClassV2 =
+  "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
 const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
 const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
 
 
 export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
 export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
   return (
   return (
-    <Show when={props.attachments.length > 0}>
-      <div class="flex flex-wrap gap-2 px-3 pt-3">
-        <For each={props.attachments}>
-          {(attachment) => (
-            <Tooltip value={attachment.filename} placement="top" contentClass="break-all">
-              <div class="relative group">
+    <Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
+      <div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
+        <div
+          data-slot="prompt-attachments-scroll"
+          classList={{
+            "flex gap-2": true,
+            "flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
+            "flex-wrap px-3 pt-3": !props.newLayoutDesigns,
+          }}
+        >
+          <Show when={props.newLayoutDesigns}>
+            <For each={props.comments ?? []}>
+              {(item) => (
+                <div class="relative group shrink-0">
+                  <TooltipV2
+                    value={item.comment}
+                    placement="top"
+                    openDelay={800}
+                    contentClass="max-w-[300px] break-words"
+                  >
+                    <CommentCardV2
+                      comment={item.comment ?? ""}
+                      path={item.path}
+                      selection={item.selection}
+                      active={props.commentActive?.(item)}
+                      onClick={() => props.onOpenComment?.(item)}
+                    />
+                  </TooltipV2>
+                  <button
+                    type="button"
+                    onClick={() => props.onRemoveComment?.(item)}
+                    class={removeClassV2}
+                    aria-label={props.removeLabel}
+                  >
+                    <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
+                  </button>
+                </div>
+              )}
+            </For>
+          </Show>
+          <For each={props.attachments}>
+            {(attachment) => {
+              const image = attachment.mime.startsWith("image/")
+              const media = () => (
                 <Show
                 <Show
-                  when={attachment.mime.startsWith("image/")}
+                  when={image}
                   fallback={
                   fallback={
-                    <div class={fallbackClass}>
-                      <Icon name="folder" class="size-6 text-text-weak" />
-                    </div>
+                    <Show
+                      when={props.newLayoutDesigns}
+                      fallback={
+                        <div class={fallbackClass}>
+                          <Icon name="folder" class="size-6 text-text-weak" />
+                        </div>
+                      }
+                    >
+                      <AttachmentCardV2 title={attachment.filename}>
+                        {typeLabel(attachment.filename, attachment.mime)}
+                      </AttachmentCardV2>
+                    </Show>
                   }
                   }
                 >
                 >
                   <img
                   <img
                     src={attachment.dataUrl}
                     src={attachment.dataUrl}
                     alt={attachment.filename}
                     alt={attachment.filename}
-                    class={imageClass}
+                    class={props.newLayoutDesigns ? imageClassV2 : imageClass}
                     onClick={() => props.onOpen(attachment)}
                     onClick={() => props.onOpen(attachment)}
                   />
                   />
                 </Show>
                 </Show>
+              )
+              const name = () => (
+                <div class={nameClass}>
+                  <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+                </div>
+              )
+              const remove = () => (
                 <button
                 <button
                   type="button"
                   type="button"
                   onClick={() => props.onRemove(attachment.id)}
                   onClick={() => props.onRemove(attachment.id)}
-                  class={removeClass}
+                  class={props.newLayoutDesigns ? removeClassV2 : removeClass}
                   aria-label={props.removeLabel}
                   aria-label={props.removeLabel}
                 >
                 >
-                  <Icon name="close" class="size-3 text-text-weak" />
+                  <Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
+                    <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
+                  </Show>
                 </button>
                 </button>
-                <div class={nameClass}>
-                  <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
-                </div>
-              </div>
-            </Tooltip>
-          )}
-        </For>
+              )
+              // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
+              return (
+                <Show
+                  when={props.newLayoutDesigns}
+                  fallback={
+                    <Tooltip value={attachment.filename} placement="top" contentClass="break-all">
+                      <div class="relative group">
+                        {media()}
+                        {name()}
+                        {remove()}
+                      </div>
+                    </Tooltip>
+                  }
+                >
+                  <div class="relative group shrink-0">
+                    <TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
+                      {media()}
+                      <Show when={image}>
+                        <div class={imageHairlineClassV2} />
+                      </Show>
+                    </TooltipV2>
+                    {remove()}
+                  </div>
+                </Show>
+              )
+            }}
+          </For>
+        </div>
+        <Show when={props.newLayoutDesigns}>
+          <div
+            data-slot="prompt-attachments-fade-left"
+            class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
+          />
+          <div
+            data-slot="prompt-attachments-fade-right"
+            class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
+          />
+        </Show>
       </div>
       </div>
     </Show>
     </Show>
   )
   )

+ 12 - 4
packages/app/src/components/prompt-workspace-selector.tsx

@@ -1,5 +1,6 @@
 import { For, Show } from "solid-js"
 import { For, Show } from "solid-js"
 import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
 import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { getFilename } from "@opencode-ai/core/util/path"
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
         {(branch) => (
         {(branch) => (
           <>
           <>
             <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
             <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
-            <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
-              <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
-              <span class="min-w-0 truncate">{branch()}</span>
-            </div>
+            <TooltipV2
+              placement="top"
+              value={branch()}
+              class="min-w-0 max-w-[220px]"
+              contentClass="max-w-[calc(100vw-32px)] break-all"
+            >
+              <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
+                <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+                <span class="min-w-0 truncate">{branch()}</span>
+              </div>
+            </TooltipV2>
           </>
           </>
         )}
         )}
       </Show>
       </Show>

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

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

+ 36 - 0
packages/app/src/components/status-popover-indicator.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
+
+describe("serverStatusDotClass", () => {
+  test("uses the success token while the server and services are healthy", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
+  })
+
+  test("uses the warning token for non-blocking issues while the server is online", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
+  })
+
+  test("uses the critical token only after the server connection drops", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
+    expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
+  })
+
+  test("stays neutral before status is ready", () => {
+    expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
+    expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
+  })
+})
+
+describe("hasNonBlockingServiceIssue", () => {
+  test("detects MCP failures that do not block chatting", () => {
+    expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
+  })
+
+  test("detects LSP failures that do not block chatting", () => {
+    expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
+  })
+})

+ 19 - 0
packages/app/src/components/status-popover-indicator.ts

@@ -0,0 +1,19 @@
+import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
+
+export function hasNonBlockingServiceIssue(input: {
+  mcp: Array<McpStatus["status"]>
+  lsp: Array<LspStatus["status"]>
+}) {
+  return (
+    input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
+    input.lsp.some((status) => status === "error")
+  )
+}
+
+export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
+  if (input.serverHealth === false) return "bg-icon-critical-base"
+  if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
+  if (input.issue) return "bg-icon-warning-base"
+  if (input.serverHealth === true) return "bg-icon-success-base"
+  return "bg-border-weak-base"
+}

+ 25 - 43
packages/app/src/components/status-popover.tsx

@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSDK } from "@/context/server-sdk"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useGlobal } from "@/context/global"
 import { useGlobal } from "@/context/global"
+import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
 
 
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
 const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +20,14 @@ export function StatusPopover() {
   const global = useGlobal()
   const global = useGlobal()
   const sync = useSync()
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
   const [shown, setShown] = createSignal(false)
-  const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
-  const mcpIssue = createMemo(() => {
-    const mcp = Object.values(sync().data.mcp ?? {})
-    const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
-    const warn = mcp.some((item) => item.status === "needs_auth")
-    if (failed) return "critical" as const
-    if (warn) return "warning" as const
-  })
-  const serverHealthy = () => global.servers.health[server.key]?.healthy === true
-  const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
+  const serverHealth = () => global.servers.health[server.key]?.healthy
+  const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
+  const issue = createMemo(() =>
+    hasNonBlockingServiceIssue({
+      mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
+      lsp: (sync().data.lsp ?? []).map((item) => item.status),
+    }),
+  )
 
 
   return (
   return (
     <Popover
     <Popover
@@ -47,13 +46,11 @@ export function StatusPopover() {
             <Icon name={shown() ? "status-active" : "status"} size="small" />
             <Icon name={shown() ? "status-active" : "status"} size="small" />
           </div>
           </div>
           <div
           <div
-            classList={{
-              "absolute -top-px -right-px size-1.5 rounded-full": true,
-              "bg-icon-success-base": ready() && healthy(),
-              "bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
-              "bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
-              "bg-border-weak-base": serverHealthy() || !ready(),
-            }}
+            class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
+              ready: ready(),
+              serverHealth: serverHealth(),
+              issue: issue(),
+            })}`}
           />
           />
         </div>
         </div>
       }
       }
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
   const sync = useSync()
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
   const [shown, setShown] = createSignal(false)
   const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
   const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
-  const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
-  const mcpIssue = createMemo(() => {
-    const mcp = Object.values(sync().data.mcp ?? {})
-    const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
-    const warn = mcp.some((item) => item.status === "needs_auth")
-    if (failed) return "critical" as const
-    if (warn) return "warning" as const
-  })
-  const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
+  const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
+  const issue = createMemo(() =>
+    hasNonBlockingServiceIssue({
+      mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
+      lsp: (sync().data.lsp ?? []).map((item) => item.status),
+    }),
+  )
   const state = createMemo<StatusPopoverState>(() => ({
   const state = createMemo<StatusPopoverState>(() => ({
     shown: shown(),
     shown: shown(),
     ready: ready(),
     ready: ready(),
-    healthy: healthy(),
     serverHealth: serverHealth(),
     serverHealth: serverHealth(),
-    issue: mcpIssue(),
+    issue: issue(),
     label: language.t("status.popover.trigger"),
     label: language.t("status.popover.trigger"),
     onOpenChange: setShown,
     onOpenChange: setShown,
     body: () => (
     body: () => (
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
   const state = createMemo<StatusPopoverState>(() => ({
   const state = createMemo<StatusPopoverState>(() => ({
     shown: shown(),
     shown: shown(),
     ready: serverHealth() !== undefined,
     ready: serverHealth() !== undefined,
-    healthy: serverHealth() === true,
     serverHealth: serverHealth(),
     serverHealth: serverHealth(),
+    issue: false,
     label: language.t("status.popover.trigger"),
     label: language.t("status.popover.trigger"),
     onOpenChange: setShown,
     onOpenChange: setShown,
     body: () => (
     body: () => (
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
 type StatusPopoverState = {
 type StatusPopoverState = {
   shown: boolean
   shown: boolean
   ready: boolean
   ready: boolean
-  healthy: boolean
   serverHealth: boolean | undefined
   serverHealth: boolean | undefined
-  issue?: "critical" | "warning"
+  issue: boolean
   label: string
   label: string
   onOpenChange: (value: boolean) => void
   onOpenChange: (value: boolean) => void
   body: () => JSX.Element
   body: () => JSX.Element
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
 }
 }
 
 
 function StatusPopoverView(props: { state: StatusPopoverState }) {
 function StatusPopoverView(props: { state: StatusPopoverState }) {
-  const statusDotClass = () => ({
-    "absolute rounded-full": true,
-    "bg-icon-success-base": props.state.ready && props.state.healthy,
-    "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
-    "bg-icon-critical-base":
-      props.state.serverHealth === false ||
-      (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
-    "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
-  })
-
   const popoverProps = {
   const popoverProps = {
     class:
     class:
       "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
       "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
         <div class="relative size-4">
         <div class="relative size-4">
           <IconV2 name={props.state.shown ? "status-active" : "status"} />
           <IconV2 name={props.state.shown ? "status-active" : "status"} />
           <div
           <div
-            classList={statusDotClass()}
-            class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
+            class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
           />
           />
         </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 {
 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 === "mint") return "cyan"
   if (key === "lime") return "green"
   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"
   return "gray"
 }
 }
 
 

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -37,6 +37,9 @@ type PlatformBase = {
   /** Open a local path in a local app (desktop only) */
   /** Open a local path in a local app (desktop only) */
   openPath?(path: string, app?: string): Promise<void>
   openPath?(path: string, app?: string): Promise<void>
 
 
+  /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
+  revealPath?(path: string): Promise<boolean>
+
   /** Restart the app  */
   /** Restart the app  */
   restart(): Promise<void>
   restart(): Promise<void>
 
 

+ 1 - 1
packages/app/src/context/prompt-state.ts

@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
   return `${key}:c=${digest.slice(0, 8)}`
   return `${key}:c=${digest.slice(0, 8)}`
 }
 }
 
 
-function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
   return item.type === "file" && !!item.comment?.trim()
   return item.type === "file" && !!item.comment?.trim()
 }
 }
 
 

+ 1 - 0
packages/app/src/context/prompt.tsx

@@ -24,6 +24,7 @@ export {
   createPromptSession,
   createPromptSession,
   createPromptState,
   createPromptState,
   DEFAULT_PROMPT,
   DEFAULT_PROMPT,
+  isCommentItem,
   isPromptEqual,
   isPromptEqual,
 } from "./prompt-state"
 } from "./prompt-state"
 export type {
 export type {

+ 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.description": "This tab points to a session that no longer exists on this server.",
   "session.error.notFound.closeTab": "Close Tab",
   "session.error.notFound.closeTab": "Close Tab",
   "session.error.serverConnection": "Can't connect to this server",
   "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.one": "Change",
   "session.review.change.other": "Changes",
   "session.review.change.other": "Changes",
   "session.review.loadingChanges": "Loading 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"
 import { shouldOpenSessionInBackground } from "./home-session-open"
 
 
 describe("shouldOpenSessionInBackground", () => {
 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", () => {
   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: {
 export function shouldOpenSessionInBackground(input: {
+  button: number
   mac: boolean
   mac: boolean
   meta: boolean
   meta: boolean
   ctrl: boolean
   ctrl: boolean
   shift: boolean
   shift: boolean
   alt: boolean
   alt: boolean
 }) {
 }) {
+  if (input.button === 1) return true
+  if (input.button !== 0) return false
   if (input.shift || input.alt) return false
   if (input.shift || input.alt) return false
   if (input.mac) return input.meta && !input.ctrl
   if (input.mac) return input.meta && !input.ctrl
   return input.ctrl && !input.meta
   return input.ctrl && !input.meta

+ 1 - 0
packages/app/src/pages/home.tsx

@@ -244,6 +244,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
 // background without navigating, matching browser conventions.
 // background without navigating, matching browser conventions.
 function isBackgroundOpen(event: MouseEvent) {
 function isBackgroundOpen(event: MouseEvent) {
   return shouldOpenSessionInBackground({
   return shouldOpenSessionInBackground({
+    button: event.button,
     mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
     mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
     meta: event.metaKey,
     meta: event.metaKey,
     ctrl: event.ctrlKey,
     ctrl: event.ctrlKey,

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

@@ -1,4 +1,5 @@
-import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import { getFilename } from "@opencode-ai/core/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
 import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
 import {
 import {
@@ -57,6 +58,8 @@ import { useSync } from "@/context/sync"
 import { useTabs } from "@/context/tabs"
 import { useTabs } from "@/context/tabs"
 import { TerminalProvider, useTerminal } from "@/context/terminal"
 import { TerminalProvider, useTerminal } from "@/context/terminal"
 import { PromptInput } from "@/components/prompt-input"
 import { PromptInput } from "@/components/prompt-input"
+import { setCursorPosition } from "@/components/prompt-input/editor-dom"
+import { promptLength } from "@/components/prompt-input/history"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import {
 import {
   createPromptInputController,
   createPromptInputController,
@@ -79,6 +82,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
 import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
 import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
 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 { 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 { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
 import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
 import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
 import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
 import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@@ -1015,7 +1019,10 @@ export default function Page() {
 
 
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
       if (composer.blocked() || isChildSession()) return
       if (composer.blocked() || isChildSession()) return
-      inputRef?.focus()
+      const input = inputRef
+      if (!input) return
+      input.focus()
+      setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
     }
     }
   }
   }
 
 
@@ -1867,7 +1874,30 @@ export default function Page() {
       .map((item) => ({ id: item.id, text: line(item.id) }))
       .map((item) => ({ id: item.id, text: line(item.id) }))
   })
   })
 
 
-  const actions = { revert }
+  // attachment bytes are embedded as a data URL, so downloading always works;
+  // revealing requires the on-disk path captured by the client that attached the file
+  const openAttachment = (file: FilePart) => {
+    const download = () => {
+      const anchor = document.createElement("a")
+      anchor.href = file.url
+      anchor.download = getFilename(file.filename) || "attachment"
+      anchor.click()
+    }
+    const path = file.filename ?? ""
+    const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
+    if (platform.revealPath && absolute) {
+      void platform.revealPath(path).then(
+        (revealed) => {
+          if (!revealed) download()
+        },
+        () => download(),
+      )
+      return
+    }
+    download()
+  }
+
+  const actions = { revert, openAttachment }
 
 
   createEffect(() => {
   createEffect(() => {
     const sessionID = params.id
     const sessionID = params.id
@@ -2232,6 +2262,13 @@ export default function Page() {
                     reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
                     reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
                     reviewCount={reviewCount}
                     reviewCount={reviewCount}
                     reviewPanel={reviewPanelV2}
                     reviewPanel={reviewPanelV2}
+                    reviewSidebarToggle={(disabled) => (
+                      <SessionReviewV2SidebarToggle
+                        opened={reviewV2State.sidebarOpened()}
+                        disabled={disabled}
+                        onToggle={reviewV2State.toggleSidebar}
+                      />
+                    )}
                     fileBrowserState={reviewV2State}
                     fileBrowserState={reviewV2State}
                     activeDiff={activeReviewFile()}
                     activeDiff={activeReviewFile()}
                     focusReviewDiff={focusReviewDiff}
                     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 { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { Dynamic } from "solid-js/web"
 import { makeEventListener } from "@solid-primitives/event-listener"
 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 { useFileComponent } from "@opencode-ai/ui/context/file"
 import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
 import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
 import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
 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 { sampledChecksum } from "@opencode-ai/core/util/encode"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 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 { Tabs } from "@opencode-ai/ui/tabs"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { showToast } from "@/utils/toast"
 import { showToast } from "@/utils/toast"
@@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
 import { useComments } from "@/context/comments"
 import { useComments } from "@/context/comments"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
 import { usePrompt } from "@/context/prompt"
+import { useSettings } from "@/context/settings"
 import { getSessionHandoff } from "@/pages/session/handoff"
 import { getSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
 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 }
 type ScrollPos = { x: number; y: number }
 
 
 function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
 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 }) {
 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 file = useFile()
   const comments = useComments()
   const comments = useComments()
   const language = useLanguage()
   const language = useLanguage()
@@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
 
 
   return content()
   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 { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { createMediaQuery } from "@solid-primitives/media"
 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 { Tabs } from "@opencode-ai/ui/tabs"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
 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 { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 
 
 import FileTree from "@/components/file-tree"
 import FileTree from "@/components/file-tree"
+import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { SessionContextUsage } from "@/components/session-context-usage"
 
 
 const reviewTabID = "session-side-panel-review-tab"
 const reviewTabID = "session-side-panel-review-tab"
 const reviewTabPanelID = "session-side-panel-review-tabpanel"
 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 { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
 import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
 import { useSettings } from "@/context/settings"
 import { useSettings } from "@/context/settings"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import { FileTabContent } from "@/pages/session/file-tabs"
@@ -47,6 +65,7 @@ export function SessionSidePanel(props: {
   reviewHasFocusableContent: () => boolean
   reviewHasFocusableContent: () => boolean
   reviewCount: () => number
   reviewCount: () => number
   reviewPanel: () => JSX.Element
   reviewPanel: () => JSX.Element
+  reviewSidebarToggle?: (disabled: boolean) => JSX.Element
   fileBrowserState?: SessionFileBrowserState
   fileBrowserState?: SessionFileBrowserState
   activeDiff?: string
   activeDiff?: string
   focusReviewDiff: (path: string) => void
   focusReviewDiff: (path: string) => void
@@ -60,7 +79,9 @@ export function SessionSidePanel(props: {
   const language = useLanguage()
   const language = useLanguage()
   const command = useCommand()
   const command = useCommand()
   const dialog = useDialog()
   const dialog = useDialog()
+  const sdk = useSDK()
   const { sessionKey, tabs, view, params } = useSessionLayout()
   const { sessionKey, tabs, view, params } = useSessionLayout()
+  const projectDirectory = createMemo(() => sdk().directory)
 
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const shown = settings.visibility.fileTree
   const shown = settings.visibility.fileTree
@@ -92,11 +113,9 @@ export function SessionSidePanel(props: {
       return "mix" as const
       return "mix" as const
     }
     }
 
 
-    const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
-
     const out = new Map<string, "add" | "del" | "mix">()
     const out = new Map<string, "add" | "del" | "mix">()
     for (const diff of diffs()) {
     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"
       const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
 
 
       out.set(file, kind)
       out.set(file, kind)
@@ -153,6 +172,7 @@ export function SessionSidePanel(props: {
     fileBrowser: () => !!props.fileBrowserState,
     fileBrowser: () => !!props.fileBrowserState,
   })
   })
   const contextOpen = tabState.contextOpen
   const contextOpen = tabState.contextOpen
+  const openFileOpen = tabState.openFileOpen
   const panelTabs = tabState.panelTabs
   const panelTabs = tabState.panelTabs
   const openedTabs = tabState.openedTabs
   const openedTabs = tabState.openedTabs
   const activeTab = tabState.activeTab
   const activeTab = tabState.activeTab
@@ -170,10 +190,8 @@ export function SessionSidePanel(props: {
     layout.fileTree.setTab("all")
     layout.fileTree.setTab("all")
   }
   }
 
 
-  const [store, setStore] = createStore({
-    activeDraggable: undefined as string | undefined,
-  })
   let fileFilter: HTMLInputElement | undefined
   let fileFilter: HTMLInputElement | undefined
+  let tabList: HTMLDivElement | undefined
   const temporaryTab = tabs().preview
   const temporaryTab = tabs().preview
   const previewTab = (value: string) => {
   const previewTab = (value: string) => {
     const next = normalizeTab(value)
     const next = normalizeTab(value)
@@ -196,10 +214,26 @@ export function SessionSidePanel(props: {
   }
   }
   const browserTab = createMemo(() => {
   const browserTab = createMemo(() => {
     if (!props.fileBrowserState) return undefined
     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()
     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 handleDragStart = (event: unknown) => {
     const id = getDraggableId(event)
     const id = getDraggableId(event)
@@ -285,72 +319,283 @@ export function SessionSidePanel(props: {
                     "bg-background-base": !settings.general.newLayoutDesigns(),
                     "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
                                 <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
                                   <IconButton
-                                    icon="close-small"
+                                    icon="plus-small"
                                     variant="ghost"
                                     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>
                                 </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>
                               </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>
                           </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()}>
                             <For each={panelTabs()}>
                               {(tab) => (
                               {(tab) => (
                                 <Show
                                 <Show
                                   when={tab === SESSION_OPEN_FILE_TAB}
                                   when={tab === SESSION_OPEN_FILE_TAB}
                                   fallback={
                                   fallback={
-                                    <SortableTab
+                                    <SortableTabV2
                                       tab={tab}
                                       tab={tab}
+                                      index={() => tabs().all().indexOf(tab)}
                                       temporary={temporaryTab() === tab}
                                       temporary={temporaryTab() === tab}
                                       onTabClose={tabs().close}
                                       onTabClose={tabs().close}
                                       onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
                                       onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@@ -386,106 +631,104 @@ export function SessionSidePanel(props: {
                                 </Show>
                                 </Show>
                               )}
                               )}
                             </For>
                             </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
                           <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>
                           </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>
                         </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>
                             </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>
                             </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>
               </div>
               </div>
             </Show>
             </Show>
@@ -513,10 +756,19 @@ export function SessionSidePanel(props: {
                   >
                   >
                     <Tabs.List>
                     <Tabs.List>
                       <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
                       <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>
                       <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
                       <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
                         {language.t("session.files.all")}
                         {language.t("session.files.all")}

+ 6 - 0
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -327,6 +327,7 @@ export function MessageTimeline(props: {
     parts: getMsgParts,
     parts: getMsgParts,
     status: sessionStatus,
     status: sessionStatus,
     showReasoningSummaries: settings.general.showReasoningSummaries,
     showReasoningSummaries: settings.general.showReasoningSummaries,
+    inlineComments: settings.general.newLayoutDesigns,
   })
   })
   const activeMessageID = projection.activeMessageID
   const activeMessageID = projection.activeMessageID
   const assistantMessagesByParent = projection.assistantMessagesByParent
   const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
           const m = messageByID().get(userMessageRow().userMessageID)
           const m = messageByID().get(userMessageRow().userMessageID)
           if (m?.role === "user") return m
           if (m?.role === "user") return m
         })
         })
+        const messageComments = createMemo(() => {
+          if (!settings.general.newLayoutDesigns()) return []
+          return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
+        })
         return (
         return (
           <TimelineRowFrame row={userMessageRow}>
           <TimelineRowFrame row={userMessageRow}>
             <Show when={message()}>
             <Show when={message()}>
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
                       parts={getMsgParts(userMessageRow().userMessageID)}
                       parts={getMsgParts(userMessageRow().userMessageID)}
                       actions={props.actions}
                       actions={props.actions}
                       useV2Actions={settings.general.newLayoutDesigns()}
                       useV2Actions={settings.general.newLayoutDesigns()}
+                      comments={messageComments()}
                     />
                     />
                   </div>
                   </div>
                 </div>
                 </div>

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

@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
   parts: (messageID: string) => Part[]
   parts: (messageID: string) => Part[]
   status: Accessor<SessionStatus>
   status: Accessor<SessionStatus>
   showReasoningSummaries: Accessor<boolean>
   showReasoningSummaries: Accessor<boolean>
+  inlineComments: Accessor<boolean>
 }) {
 }) {
   const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
   const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
   const assistantMessagesByParent = createMemo(() => {
   const assistantMessagesByParent = createMemo(() => {
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
             input.showReasoningSummaries(),
             input.showReasoningSummaries(),
             input.status().type,
             input.status().type,
             activeMessageID() === userMessage.id,
             activeMessageID() === userMessage.id,
+            input.inlineComments(),
           ),
           ),
         ),
         ),
       ),
       ),

+ 4 - 2
packages/app/src/pages/session/timeline/rows.ts

@@ -38,6 +38,8 @@ export namespace Timeline {
     showReasoning: boolean,
     showReasoning: boolean,
     status: SessionStatus["type"],
     status: SessionStatus["type"],
     isActive: boolean,
     isActive: boolean,
+    // v2 renders comments inside the user message attachments row instead of a strip row
+    inlineComments: boolean,
   ) {
   ) {
     const rows: TimelineRow.TimelineRow[] = []
     const rows: TimelineRow.TimelineRow[] = []
 
 
@@ -74,7 +76,7 @@ export namespace Timeline {
         : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
         : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
     if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
     if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
 
 
-    if (comments.length > 0)
+    if (comments.length > 0 && !inlineComments)
       rows.push(
       rows.push(
         new TimelineRow.CommentStrip({
         new TimelineRow.CommentStrip({
           userMessageID: userMessage.id,
           userMessageID: userMessage.id,
@@ -84,7 +86,7 @@ export namespace Timeline {
     rows.push(
     rows.push(
       new TimelineRow.UserMessage({
       new TimelineRow.UserMessage({
         userMessageID: userMessage.id,
         userMessageID: userMessage.id,
-        anchor: comments.length === 0,
+        anchor: inlineComments || comments.length === 0,
       }),
       }),
     )
     )
 
 

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

@@ -5,7 +5,6 @@ import {
   SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
   SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
   SessionReviewV2,
   SessionReviewV2,
   SessionReviewV2Sidebar,
   SessionReviewV2Sidebar,
-  SessionReviewV2SidebarToggle,
 } from "@opencode-ai/session-ui/v2/session-review-v2"
 } from "@opencode-ai/session-ui/v2/session-review-v2"
 import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
 import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
 import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
 import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -65,6 +64,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
   )
   )
   const searching = createMemo(() => props.state.filter().trim().length > 0)
   const searching = createMemo(() => props.state.filter().trim().length > 0)
   const kinds = createMemo(() => reviewDiffKinds(diffs()))
   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(() => {
   const activeDiff = createMemo(() => {
     // A focused comment takes over the preview until the preview applies it and
     // 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.
     // clears the focus; the owner then persists the file as the active selection.
@@ -112,9 +113,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
       stats={<DiffChanges changes={diffs()} />}
       stats={<DiffChanges changes={diffs()} />}
       empty={props.empty}
       empty={props.empty}
       sidebarOpen={props.state.sidebarOpened()}
       sidebarOpen={props.state.sidebarOpened()}
-      sidebarToggle={
-        <SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
-      }
       sidebar={
       sidebar={
         // Always mounted: the sidebar header hosts the changes-mode dropdown,
         // Always mounted: the sidebar header hosts the changes-mode dropdown,
         // which must stay reachable when the current mode has zero diffs.
         // which must stay reachable when the current mode has zero diffs.
@@ -126,7 +124,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
           diffs={diffs}
           diffs={diffs}
           filteredFiles={filteredFiles}
           filteredFiles={filteredFiles}
           searching={searching}
           searching={searching}
-          kinds={kinds}
+          kinds={treeKinds}
           activeDiff={activeDiff}
           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 { createMemo, createSignal, createUniqueId, Show } from "solid-js"
 import { createQuery } from "@tanstack/solid-query"
 import { createQuery } from "@tanstack/solid-query"
-import { Tabs } from "@opencode-ai/ui/tabs"
 import { Icon } from "@opencode-ai/ui/icon"
 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 { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 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 (
   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
             <Show
-              when={query()}
+              when={!loading()}
               fallback={
               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
               <Show
-                when={!loading()}
+                when={files().length > 0}
                 fallback={
                 fallback={
                   <div role="status" class="px-2 py-2 text-12-regular text-text-weak">
                   <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>
                   </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>
             </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>
   )
   )
 }
 }

+ 43 - 16
packages/app/test-browser/solid-virtual.test.ts

@@ -1,8 +1,32 @@
 import { expect, test } from "bun:test"
 import { expect, test } from "bun:test"
-import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
 import { createRoot, createSignal } from "solid-js"
 import { createRoot, createSignal } from "solid-js"
 import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
 import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
 
 
+test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
+  const writes: { offset: number; adjustments?: number }[] = []
+  const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
+    count: 5,
+    estimateSize: () => 50,
+    initialOffset: 50,
+    initialRect: { width: 400, height: 200 },
+    anchorTo: "end",
+    scrollEndThreshold: 1,
+    getScrollElement: () => null,
+    scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
+    observeElementRect: () => {},
+    observeElementOffset: () => {},
+  })
+
+  virtualizer.getTotalSize()
+  virtualizer.resizeItem(4, 120)
+  expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
+  writes.length = 0
+
+  virtualizer.resizeItem(4, 200)
+  expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
+})
+
 test("reactive count updates preserve measured row sizes", () => {
 test("reactive count updates preserve measured row sizes", () => {
   createRoot((dispose) => {
   createRoot((dispose) => {
     const [count, setCount] = createSignal(2)
     const [count, setCount] = createSignal(2)
@@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
   })
   })
 })
 })
 
 
-test("logical scroll offset includes pending measurement adjustments", () => {
-  createRoot((dispose) => {
-    const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
-      count: 2,
-      getScrollElement: () => null,
-      estimateSize: () => 60,
-      initialOffset: 100,
-      initialRect: { width: 800, height: 60 },
-    })
+test("clamps oversized offsets with scroll margin and padding changes", () => {
+  const options = (paddingEnd: number) => ({
+    count: 20,
+    estimateSize: () => 60,
+    initialOffset: Number.MAX_SAFE_INTEGER,
+    initialRect: { width: 800, height: 600 },
+    scrollMargin: 64,
+    paddingEnd,
+    overscan: 1,
+    getScrollElement: () => null,
+    scrollToFn: () => {},
+    observeElementRect: () => {},
+    observeElementOffset: () => {},
+  })
+  const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
 
 
-    virtualizer.getTotalSize()
-    virtualizer.resizeItem(0, 100)
+  expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
 
 
-    expect(virtualizer.scrollOffset).toBe(100)
-    expect(virtualizer.getLogicalScrollOffset()).toBe(140)
-    dispose()
-  })
+  virtualizer.setOptions(options(600))
+  expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
 })
 })
 
 
 test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
 test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {

+ 1 - 1
packages/codemode/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/codemode",
   "name": "@opencode-ai/codemode",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "description": "Effect-native confined code execution over schema-described tools",
   "description": "Effect-native confined code execution over schema-described tools",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-app",
   "name": "@opencode-ai/console-app",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
   "name": "@opencode-ai/console-core",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-function",
   "name": "@opencode-ai/console-function",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-mail",
   "name": "@opencode-ai/console-mail",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "dependencies": {
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",
     "@jsx-email/cli": "1.4.3",

+ 1 - 1
packages/console/support/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-support",
   "name": "@opencode-ai/console-support",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 4 - 4
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "name": "@opencode-ai/core",
   "name": "@opencode-ai/core",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
@@ -64,7 +64,7 @@
     "@ai-sdk/alibaba": "1.0.17",
     "@ai-sdk/alibaba": "1.0.17",
     "@ai-sdk/amazon-bedrock": "4.0.112",
     "@ai-sdk/amazon-bedrock": "4.0.112",
     "@ai-sdk/anthropic": "3.0.82",
     "@ai-sdk/anthropic": "3.0.82",
-    "@ai-sdk/azure": "3.0.49",
+    "@ai-sdk/azure": "3.0.88",
     "@ai-sdk/cerebras": "2.0.41",
     "@ai-sdk/cerebras": "2.0.41",
     "@ai-sdk/cohere": "3.0.27",
     "@ai-sdk/cohere": "3.0.27",
     "@ai-sdk/deepinfra": "2.0.41",
     "@ai-sdk/deepinfra": "2.0.41",
@@ -73,7 +73,7 @@
     "@ai-sdk/google-vertex": "4.0.128",
     "@ai-sdk/google-vertex": "4.0.128",
     "@ai-sdk/groq": "3.0.31",
     "@ai-sdk/groq": "3.0.31",
     "@ai-sdk/mistral": "3.0.27",
     "@ai-sdk/mistral": "3.0.27",
-    "@ai-sdk/openai": "3.0.53",
+    "@ai-sdk/openai": "3.0.84",
     "@ai-sdk/openai-compatible": "2.0.41",
     "@ai-sdk/openai-compatible": "2.0.41",
     "@ai-sdk/perplexity": "3.0.26",
     "@ai-sdk/perplexity": "3.0.26",
     "@ai-sdk/provider": "3.0.8",
     "@ai-sdk/provider": "3.0.8",
@@ -110,7 +110,7 @@
     "drizzle-orm": "catalog:",
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "6.10.0",
+    "gitlab-ai-provider": "6.11.1",
     "glob": "13.0.5",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",
     "gray-matter": "4.0.3",

+ 1 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "@opencode-ai/desktop",
   "name": "@opencode-ai/desktop",
   "private": true,
   "private": true,
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "homepage": "https://opencode.ai",
   "homepage": "https://opencode.ai",

+ 10 - 0
packages/desktop/src/main/ipc.ts

@@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) {
     })
     })
   })
   })
 
 
+  ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
+    const exists = await stat(path).then(
+      () => true,
+      () => false,
+    )
+    if (!exists) return false
+    shell.showItemInFolder(path)
+    return true
+  })
+
   ipcMain.handle("read-clipboard-image", () => {
   ipcMain.handle("read-clipboard-image", () => {
     const image = clipboard.readImage()
     const image = clipboard.readImage()
     if (image.isEmpty()) return null
     if (image.isEmpty()) return null

+ 1 - 0
packages/desktop/src/preload/index.ts

@@ -95,6 +95,7 @@ const api: ElectronAPI = {
   saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
   saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
   openLink: (url) => ipcRenderer.send("open-link", url),
   openLink: (url) => ipcRenderer.send("open-link", url),
   openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
   openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
+  revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
   readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
   readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
   showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
   showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
   getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
   getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),

+ 1 - 0
packages/desktop/src/preload/types.ts

@@ -86,6 +86,7 @@ export type ElectronAPI = {
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   openLink: (url: string) => void
   openLink: (url: string) => void
   openPath: (path: string, app?: string) => Promise<void>
   openPath: (path: string, app?: string) => Promise<void>
+  revealPath: (path: string) => Promise<boolean>
   readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
   readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
   showNotification: (title: string, body?: string) => void
   showNotification: (title: string, body?: string) => void
   getWindowFocused: () => Promise<boolean>
   getWindowFocused: () => Promise<boolean>

+ 3 - 0
packages/desktop/src/renderer/index.tsx

@@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
       }
       }
       return window.api.openPath(path, app)
       return window.api.openPath(path, app)
     },
     },
+    async revealPath(path: string) {
+      return window.api.revealPath(path)
+    },
 
 
     back() {
     back() {
       window.history.back()
       window.history.back()

+ 1 - 1
packages/effect-drizzle-sqlite/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "name": "@opencode-ai/effect-drizzle-sqlite",
   "name": "@opencode-ai/effect-drizzle-sqlite",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/effect-sqlite-node/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "name": "@opencode-ai/effect-sqlite-node",
   "name": "@opencode-ai/effect-sqlite-node",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/enterprise",
   "name": "@opencode-ai/enterprise",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/function",
   "name": "@opencode-ai/function",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 1 - 1
packages/http-recorder/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "name": "@opencode-ai/http-recorder",
   "name": "@opencode-ai/http-recorder",
   "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
   "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
   "type": "module",
   "type": "module",

+ 1 - 1
packages/llm/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "name": "@opencode-ai/llm",
   "name": "@opencode-ai/llm",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 4 - 4
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "name": "opencode",
   "name": "opencode",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
@@ -58,7 +58,7 @@
     "@ai-sdk/alibaba": "1.0.17",
     "@ai-sdk/alibaba": "1.0.17",
     "@ai-sdk/amazon-bedrock": "4.0.112",
     "@ai-sdk/amazon-bedrock": "4.0.112",
     "@ai-sdk/anthropic": "3.0.82",
     "@ai-sdk/anthropic": "3.0.82",
-    "@ai-sdk/azure": "3.0.49",
+    "@ai-sdk/azure": "3.0.88",
     "@ai-sdk/cerebras": "2.0.60",
     "@ai-sdk/cerebras": "2.0.60",
     "@ai-sdk/cohere": "3.0.27",
     "@ai-sdk/cohere": "3.0.27",
     "@ai-sdk/deepinfra": "2.0.41",
     "@ai-sdk/deepinfra": "2.0.41",
@@ -67,7 +67,7 @@
     "@ai-sdk/google-vertex": "4.0.128",
     "@ai-sdk/google-vertex": "4.0.128",
     "@ai-sdk/groq": "3.0.31",
     "@ai-sdk/groq": "3.0.31",
     "@ai-sdk/mistral": "3.0.27",
     "@ai-sdk/mistral": "3.0.27",
-    "@ai-sdk/openai": "3.0.53",
+    "@ai-sdk/openai": "3.0.84",
     "@ai-sdk/openai-compatible": "2.0.41",
     "@ai-sdk/openai-compatible": "2.0.41",
     "@ai-sdk/perplexity": "3.0.26",
     "@ai-sdk/perplexity": "3.0.26",
     "@ai-sdk/provider": "3.0.8",
     "@ai-sdk/provider": "3.0.8",
@@ -122,7 +122,7 @@
     "drizzle-orm": "catalog:",
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "6.10.0",
+    "gitlab-ai-provider": "6.11.1",
     "glob": "13.0.5",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",
     "gray-matter": "4.0.3",

+ 13 - 1
packages/opencode/src/account/account.ts

@@ -348,6 +348,18 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
       return yield* fetchOrgs(account.url, accessToken)
       return yield* fetchOrgs(account.url, accessToken)
     })
     })
 
 
+    const remove = Effect.fn("Account.remove")(function* (accountID: AccountID) {
+      const active = yield* repo.active()
+      yield* repo.remove(accountID)
+      if (Option.isNone(active) || active.value.id !== accountID) return
+
+      const next = (yield* orgsByAccount()).flatMap((group) =>
+        group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })),
+      )[0]
+      if (!next) return
+      yield* repo.use(next.accountID, Option.some(next.orgID))
+    })
+
     const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
     const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
       const resolved = yield* resolveAccess(accountID)
       const resolved = yield* resolveAccess(accountID)
       if (Option.isNone(resolved)) return Option.none()
       if (Option.isNone(resolved)) return Option.none()
@@ -445,7 +457,7 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
       activeOrg,
       activeOrg,
       list: repo.list,
       list: repo.list,
       orgsByAccount,
       orgsByAccount,
-      remove: repo.remove,
+      remove,
       use: repo.use,
       use: repo.use,
       orgs,
       orgs,
       config,
       config,

+ 3 - 0
packages/opencode/src/plugin/openai/codex.ts

@@ -283,8 +283,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
         return Object.fromEntries(
         return Object.fromEntries(
           Object.entries(provider.models)
           Object.entries(provider.models)
             .filter(([, model]) => {
             .filter(([, model]) => {
+              if (model.options.reasoningMode === "pro") return false
               if (ALLOWED_MODELS.has(model.api.id)) return true
               if (ALLOWED_MODELS.has(model.api.id)) return true
               if (DISALLOWED_MODELS.has(model.api.id)) return false
               if (DISALLOWED_MODELS.has(model.api.id)) return false
+              if (model.api.id === "gpt-5.6") return false
               const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
               const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
               return match ? parseFloat(match[1]) > 5.4 : false
               return match ? parseFloat(match[1]) > 5.4 : false
             })
             })
@@ -416,6 +418,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
 
 
             const requestInit = {
             const requestInit = {
               ...init,
               ...init,
+              body: init?.body,
               headers,
               headers,
             }
             }
             if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
             if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)

+ 21 - 12
packages/opencode/src/provider/provider.ts

@@ -1266,6 +1266,14 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
       // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data.
       // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data.
       const id = `${model.id}-${mode}`
       const id = `${model.id}-${mode}`
       const base = fromModelsDevModel(provider, model)
       const base = fromModelsDevModel(provider, model)
+      const providerOptions = (
+        opts as {
+          readonly provider?: {
+            readonly body?: Record<string, unknown>
+            readonly headers?: Record<string, string>
+          }
+        }
+      ).provider
       models[id] = {
       models[id] = {
         ...base,
         ...base,
         id: ModelV2.ID.make(id),
         id: ModelV2.ID.make(id),
@@ -1273,18 +1281,8 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
         name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
         name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
         // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data.
         // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data.
         cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
         cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
-        // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data.
-        options: opts.provider?.body
-          ? Object.fromEntries(
-              // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data.
-              Object.entries(opts.provider.body).map(([k, v]) => [
-                k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
-                v,
-              ]),
-            )
-          : base.options,
-        // @ts-expect-error dead V1 expects raw mode headers inside normalized ModelsDev data.
-        headers: opts.provider?.headers ?? base.headers,
+        options: modeOptions(base, providerOptions?.body),
+        headers: providerOptions?.headers ?? base.headers,
       }
       }
     }
     }
   }
   }
@@ -1298,6 +1296,17 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
   }
   }
 }
 }
 
 
+function modeOptions(model: Model, body: Record<string, unknown> | undefined) {
+  if (!body) return model.options
+  const options = Object.fromEntries(
+    Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]),
+  )
+  const reasoning = body.reasoning
+  if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options
+  const { reasoning: _, ...rest } = options
+  return { ...rest, reasoningMode: reasoning.mode }
+}
+
 function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
 function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
   const available = provider
   const available = provider
     ? Object.keys(provider.models).filter((id) => {
     ? Object.keys(provider.models).filter((id) => {

+ 8 - 6
packages/opencode/src/provider/transform.ts

@@ -761,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
     }
     }
 
 
     case "@ai-sdk/gateway":
     case "@ai-sdk/gateway":
-      if (model.id.includes("anthropic")) {
+      if (model.api.id.includes("anthropic")) {
         if (adaptiveEfforts) {
         if (adaptiveEfforts) {
           return Object.fromEntries(
           return Object.fromEntries(
             adaptiveEfforts.map((effort) => [
             adaptiveEfforts.map((effort) => [
@@ -794,8 +794,8 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
           },
           },
         }
         }
       }
       }
-      if (model.id.includes("google")) {
-        if (id.includes("2.5")) {
+      if (model.api.id.includes("google")) {
+        if (model.api.id.includes("2.5")) {
           return {
           return {
             high: {
             high: {
               thinkingConfig: {
               thinkingConfig: {
@@ -806,7 +806,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
             max: {
             max: {
               thinkingConfig: {
               thinkingConfig: {
                 includeThoughts: true,
                 includeThoughts: true,
-                thinkingBudget: googleThinkingBudgetMax(id),
+                thinkingBudget: googleThinkingBudgetMax(model.api.id.toLowerCase()),
               },
               },
             },
             },
           }
           }
@@ -1104,7 +1104,8 @@ export function options(input: {
     input.model.providerID === "openai" ||
     input.model.providerID === "openai" ||
     input.model.api.npm === "@ai-sdk/openai" ||
     input.model.api.npm === "@ai-sdk/openai" ||
     input.model.api.npm === "@ai-sdk/github-copilot" ||
     input.model.api.npm === "@ai-sdk/github-copilot" ||
-    input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
+    input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" ||
+    input.model.api.npm === "@ai-sdk/xai"
   ) {
   ) {
     result["store"] = false
     result["store"] = false
   }
   }
@@ -1259,7 +1260,8 @@ export function smallOptions(model: Provider.Model) {
   if (
   if (
     model.providerID === "openai" ||
     model.providerID === "openai" ||
     model.api.npm === "@ai-sdk/openai" ||
     model.api.npm === "@ai-sdk/openai" ||
-    model.api.npm === "@ai-sdk/github-copilot"
+    model.api.npm === "@ai-sdk/github-copilot" ||
+    model.api.npm === "@ai-sdk/xai"
   ) {
   ) {
     const base = { store: false }
     const base = { store: false }
     return mergeDeep(base, small)
     return mergeDeep(base, small)

+ 47 - 0
packages/opencode/test/account/service.test.ts

@@ -173,6 +173,53 @@ it.live("orgsByAccount groups orgs per account", () =>
   }),
   }),
 )
 )
 
 
+it.live("remove switches to another org when the active account is removed", () =>
+  Effect.gen(function* () {
+    const first = AccountID.make("user-1")
+    const second = AccountID.make("user-2")
+
+    yield* AccountRepo.Service.use((r) =>
+      r.persistAccount({
+        id: first,
+        email: "one@example.com",
+        url: "https://one.example.com",
+        accessToken: AccessToken.make("at_1"),
+        refreshToken: RefreshToken.make("rt_1"),
+        expiry: Date.now() + outsideEagerRefreshWindow,
+        orgID: Option.some(OrgID.make("org-1")),
+      }),
+    )
+
+    yield* AccountRepo.Service.use((r) =>
+      r.persistAccount({
+        id: second,
+        email: "two@example.com",
+        url: "https://two.example.com",
+        accessToken: AccessToken.make("at_2"),
+        refreshToken: RefreshToken.make("rt_2"),
+        expiry: Date.now() + outsideEagerRefreshWindow,
+        orgID: Option.some(OrgID.make("org-2")),
+      }),
+    )
+
+    const client = HttpClient.make((req) =>
+      Effect.succeed(
+        req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404),
+      ),
+    )
+
+    yield* Account.use.remove(second).pipe(Effect.provide(live(client)))
+
+    const active = yield* AccountRepo.use.active()
+    expect(Option.getOrThrow(active)).toEqual(
+      expect.objectContaining({
+        id: first,
+        active_org_id: OrgID.make("org-1"),
+      }),
+    )
+  }),
+)
+
 it.live("token refresh persists the new token", () =>
 it.live("token refresh persists the new token", () =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const id = AccountID.make("user-1")
     const id = AccountID.make("user-1")

+ 26 - 7
packages/opencode/test/plugin/codex.test.ts

@@ -149,16 +149,32 @@ describe("plugin.codex", () => {
     await enabled.dispose?.()
     await enabled.dispose?.()
   })
   })
 
 
-  test("uses Codex context limits for OAuth GPT models", async () => {
+  test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => {
     const hooks = await CodexAuthPlugin({} as never)
     const hooks = await CodexAuthPlugin({} as never)
     const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
     const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
     const provider = {
     const provider = {
-      models: Object.fromEntries(
-        ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [
-          id,
-          { id, api: { id }, limit, cost: {} },
-        ]),
-      ),
+      models: {
+        ...Object.fromEntries(
+          ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [
+            id,
+            { id, api: { id }, limit, cost: {}, options: {} },
+          ]),
+        ),
+        "gpt-5.4-pro": {
+          id: "gpt-5.4-pro",
+          api: { id: "gpt-5.4" },
+          limit,
+          cost: {},
+          options: { reasoningMode: "pro" },
+        },
+        "gpt-5.6-sol-high": {
+          id: "gpt-5.6-sol-high",
+          api: { id: "gpt-5.6-sol" },
+          limit,
+          cost: {},
+          options: { reasoningEffort: "high" },
+        },
+      },
     }
     }
 
 
     const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
     const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
@@ -168,6 +184,9 @@ describe("plugin.codex", () => {
     expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
     expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
     expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
     expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
     expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
     expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
+    expect(models["gpt-5.4-pro"]).toBeUndefined()
+    expect(models["gpt-5.7-pro"]).toBeDefined()
+    expect(models["gpt-5.6-sol-high"]).toBeDefined()
     expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
     expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
       provider.models as never,
       provider.models as never,
     )
     )

+ 17 - 5
packages/opencode/test/provider/provider.test.ts

@@ -1339,16 +1339,17 @@ it.instance(
   },
   },
 )
 )
 
 
-test("mode cost preserves over-200k pricing from base model", () => {
+test("mode options and cost are derived from the base model", () => {
   const provider = {
   const provider = {
     id: "openai",
     id: "openai",
     name: "OpenAI",
     name: "OpenAI",
     env: [],
     env: [],
+    npm: "@ai-sdk/openai",
     api: "https://api.openai.com/v1",
     api: "https://api.openai.com/v1",
     models: {
     models: {
-      "gpt-5.4": {
-        id: "gpt-5.4",
-        name: "GPT-5.4",
+      "gpt-5.6-sol": {
+        id: "gpt-5.6-sol",
+        name: "GPT-5.6 Sol",
         family: "gpt",
         family: "gpt",
         release_date: "2026-03-05",
         release_date: "2026-03-05",
         attachment: true,
         attachment: true,
@@ -1384,18 +1385,29 @@ test("mode cost preserves over-200k pricing from base model", () => {
                 },
                 },
               },
               },
             },
             },
+            pro: {
+              provider: {
+                body: {
+                  reasoning: { mode: "pro" },
+                  service_tier: "priority",
+                },
+              },
+            },
           },
           },
         },
         },
       },
       },
     },
     },
   } as unknown as ModelsDevProvider
   } as unknown as ModelsDevProvider
 
 
-  const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
+  const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"]
   expect(model.cost.input).toEqual(5)
   expect(model.cost.input).toEqual(5)
   expect(model.cost.output).toEqual(30)
   expect(model.cost.output).toEqual(30)
   expect(model.cost.cache.read).toEqual(0.5)
   expect(model.cost.cache.read).toEqual(0.5)
   expect(model.cost.cache.write).toEqual(0)
   expect(model.cost.cache.write).toEqual(0)
   expect(model.options["serviceTier"]).toEqual("priority")
   expect(model.options["serviceTier"]).toEqual("priority")
+  const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"]
+  expect(pro.api.id).toEqual("gpt-5.6-sol")
+  expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" })
   expect(model.cost.experimentalOver200K).toEqual({
   expect(model.cost.experimentalOver200K).toEqual({
     input: 5,
     input: 5,
     output: 22.5,
     output: 22.5,

+ 73 - 0
packages/opencode/test/provider/transform.test.ts

@@ -155,6 +155,43 @@ describe("ProviderTransform.options - setCacheKey", () => {
     expect(result.store).toBe(false)
     expect(result.store).toBe(false)
   })
   })
 
 
+  test("should set store=false for xAI provider by default", () => {
+    const xaiModel = {
+      ...mockModel,
+      providerID: "xai",
+      api: {
+        id: "grok-4",
+        url: "https://api.x.ai",
+        npm: "@ai-sdk/xai",
+      },
+    }
+    const result = ProviderTransform.options({
+      model: xaiModel,
+      sessionID,
+      providerOptions: {},
+    })
+    expect(result.store).toBe(false)
+    expect(result.promptCacheKey).toBe(sessionID)
+  })
+
+  test("should set store=false for xAI SDK regardless of provider ID", () => {
+    const xaiModel = {
+      ...mockModel,
+      providerID: "custom-xai",
+      api: {
+        id: "grok-4",
+        url: "https://api.x.ai",
+        npm: "@ai-sdk/xai",
+      },
+    }
+    const result = ProviderTransform.options({
+      model: xaiModel,
+      sessionID,
+      providerOptions: {},
+    })
+    expect(result.store).toBe(false)
+  })
+
   test("should set store=false for azure provider by default", () => {
   test("should set store=false for azure provider by default", () => {
     const azureModel = {
     const azureModel = {
       ...mockModel,
       ...mockModel,
@@ -3372,6 +3409,42 @@ describe("ProviderTransform.variants", () => {
   })
   })
 
 
   describe("@ai-sdk/gateway", () => {
   describe("@ai-sdk/gateway", () => {
+    test("configured anthropic aliases route by the API ID", () => {
+      const model = createMockModel({
+        id: "my-claude",
+        providerID: "gateway",
+        api: {
+          id: "anthropic/claude-sonnet-4-6",
+          url: "https://gateway.ai",
+          npm: "@ai-sdk/gateway",
+        },
+      })
+      const result = ProviderTransform.variants(model)
+      expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
+      expect(result.high).toEqual({
+        thinking: {
+          type: "adaptive",
+        },
+        effort: "high",
+      })
+    })
+
+    test("configured google aliases route by the API ID", () => {
+      const model = createMockModel({
+        id: "my-gemini",
+        providerID: "gateway",
+        api: {
+          id: "google/gemini-2.5-pro",
+          url: "https://gateway.ai",
+          npm: "@ai-sdk/gateway",
+        },
+      })
+      expect(ProviderTransform.variants(model)).toEqual({
+        high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } },
+        max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } },
+      })
+    })
+
     test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
     test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
       const model = createMockModel({
       const model = createMockModel({
         id: "anthropic/claude-sonnet-4-6",
         id: "anthropic/claude-sonnet-4-6",

+ 2 - 1
packages/opencode/test/session/llm.test.ts

@@ -1095,7 +1095,7 @@ describe("session.llm.stream", () => {
         const agent = {
         const agent = {
           name: "test",
           name: "test",
           mode: "primary",
           mode: "primary",
-          options: {},
+          options: { reasoningMode: "pro" },
           permission: [{ permission: "*", pattern: "*", action: "allow" }],
           permission: [{ permission: "*", pattern: "*", action: "allow" }],
           temperature: 0.2,
           temperature: 0.2,
         } satisfies Agent.Info
         } satisfies Agent.Info
@@ -1126,6 +1126,7 @@ describe("session.llm.stream", () => {
         expect(body.model).toBe(resolved.api.id)
         expect(body.model).toBe(resolved.api.id)
         expect(body.stream).toBe(true)
         expect(body.stream).toBe(true)
         expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
         expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
+        expect((body.reasoning as { mode?: string } | undefined)?.mode).toBe("pro")
 
 
         const maxTokens = body.max_output_tokens as number | undefined
         const maxTokens = body.max_output_tokens as number | undefined
         expect(maxTokens).toBe(undefined) // match codex cli behavior
         expect(maxTokens).toBe(undefined) // match codex cli behavior

+ 1 - 1
packages/plugin/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/plugin",
   "name": "@opencode-ai/plugin",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/sdk/js/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/sdk",
   "name": "@opencode-ai/sdk",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/server/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/server",
   "name": "@opencode-ai/server",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/session-ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/session-ui",
   "name": "@opencode-ai/session-ui",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 11 - 1
packages/session-ui/src/components/message-file.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import type { FilePart } from "@opencode-ai/sdk/v2"
 import type { FilePart } from "@opencode-ai/sdk/v2"
-import { attached, inline, kind } from "./message-file"
+import { attached, inline, kind, typeLabel } from "./message-file"
 
 
 function file(part: Partial<FilePart> = {}): FilePart {
 function file(part: Partial<FilePart> = {}): FilePart {
   return {
   return {
@@ -52,4 +52,14 @@ describe("message-file", () => {
     expect(kind(file({ mime: "image/png" }))).toBe("image")
     expect(kind(file({ mime: "image/png" }))).toBe("image")
     expect(kind(file({ mime: "application/pdf" }))).toBe("file")
     expect(kind(file({ mime: "application/pdf" }))).toBe("file")
   })
   })
+
+  test("labels attachment types from the basename extension", () => {
+    expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
+    expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
+    expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
+    expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
+    expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
+    expect(typeLabel(".gitignore", "text/plain")).toBe("File")
+    expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
+  })
 })
 })

+ 21 - 0
packages/session-ui/src/components/message-file.ts

@@ -1,3 +1,5 @@
+import { bundledLanguagesInfo } from "shiki"
+import { getFilename } from "@opencode-ai/core/util/path"
 import type { FilePart } from "@opencode-ai/sdk/v2"
 import type { FilePart } from "@opencode-ai/sdk/v2"
 
 
 export function attached(part: FilePart) {
 export function attached(part: FilePart) {
@@ -12,3 +14,22 @@ export function inline(part: FilePart) {
 export function kind(part: FilePart) {
 export function kind(part: FilePart) {
   return part.mime.startsWith("image/") ? "image" : "file"
   return part.mime.startsWith("image/") ? "image" : "file"
 }
 }
+
+// language metadata only; grammars stay behind shiki's lazy imports
+const LANGUAGE_NAMES = new Map<string, string>(
+  bundledLanguagesInfo.flatMap((info) =>
+    [info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]),
+  ),
+)
+
+// attachments carry text/plain for all text files, so the label comes from the extension;
+// filename may be an absolute path, so extract the basename before looking for one
+export function typeLabel(filename: string, mime: string) {
+  if (mime === "application/pdf") return "PDF"
+  const base = getFilename(filename)
+  // idx 0 is a dotfile like .gitignore, not an extension
+  const idx = base.lastIndexOf(".")
+  const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
+  if (!suffix) return "File"
+  return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
+}

+ 24 - 2
packages/session-ui/src/components/message-part.css

@@ -57,8 +57,20 @@
     }
     }
 
 
     &[data-type="image"] {
     &[data-type="image"] {
-      width: 48px;
-      height: 48px;
+      position: relative;
+      width: 58px;
+      height: 46px;
+      border: none;
+
+      /* inset box-shadows do not paint over <img> content, so the hairline is an overlay */
+      &::after {
+        content: "";
+        position: absolute;
+        inset: 0;
+        border-radius: inherit;
+        box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
+        pointer-events: none;
+      }
     }
     }
 
 
     &[data-type="file"] {
     &[data-type="file"] {
@@ -1301,6 +1313,16 @@ body:not([data-new-layout]) {
       &:hover {
       &:hover {
         border-color: var(--border-strong-base);
         border-color: var(--border-strong-base);
       }
       }
+
+      &[data-type="image"] {
+        width: 48px;
+        height: 48px;
+        border: 1px solid var(--border-weak-base);
+
+        &::after {
+          content: none;
+        }
+      }
     }
     }
 
 
     [data-slot="user-message-attachment-name"] {
     [data-slot="user-message-attachment-name"] {

+ 62 - 21
packages/session-ui/src/components/message-part.tsx

@@ -45,6 +45,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { Markdown } from "./markdown"
 import { Markdown } from "./markdown"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import { AttachmentCardV2 } from "../v2/components/attachment-card-v2"
+import { CommentCardV2 } from "../v2/components/comment-card-v2"
 import { checksum } from "@opencode-ai/core/util/encode"
 import { checksum } from "@opencode-ai/core/util/encode"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -58,7 +60,7 @@ import { ToolStatusTitle } from "./tool-status-title"
 import { patchFiles } from "./apply-patch-file"
 import { patchFiles } from "./apply-patch-file"
 import { animate } from "motion"
 import { animate } from "motion"
 import { useLocation } from "@solidjs/router"
 import { useLocation } from "@solidjs/router"
-import { attached, inline, kind } from "./message-file"
+import { attached, inline, kind, typeLabel } from "./message-file"
 import { readPartText } from "./message-part-text"
 import { readPartText } from "./message-part-text"
 import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
 import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
 
 
@@ -165,6 +167,7 @@ export interface MessageProps {
   showAssistantCopyPartID?: string | null
   showAssistantCopyPartID?: string | null
   showReasoningSummaries?: boolean
   showReasoningSummaries?: boolean
   useV2Actions?: boolean
   useV2Actions?: boolean
+  comments?: UserMessageComment[]
 }
 }
 
 
 export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
 export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -172,6 +175,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) =>
 export type UserActions = {
 export type UserActions = {
   fork?: SessionAction
   fork?: SessionAction
   revert?: SessionAction
   revert?: SessionAction
+  openAttachment?: (file: FilePart) => void
+}
+
+export type UserMessageComment = {
+  path: string
+  comment: string
+  selection?: {
+    startLine: number
+    endLine: number
+  }
 }
 }
 
 
 export interface MessagePartProps {
 export interface MessagePartProps {
@@ -937,6 +950,7 @@ export function Message(props: MessageProps) {
             parts={props.parts}
             parts={props.parts}
             actions={props.actions}
             actions={props.actions}
             useV2Actions={props.useV2Actions}
             useV2Actions={props.useV2Actions}
+            comments={props.comments}
           />
           />
         )}
         )}
       </Match>
       </Match>
@@ -1162,6 +1176,7 @@ export function UserMessageDisplay(props: {
   parts: PartType[]
   parts: PartType[]
   actions?: UserActions
   actions?: UserActions
   useV2Actions?: boolean
   useV2Actions?: boolean
+  comments?: UserMessageComment[]
 }) {
 }) {
   const data = useData()
   const data = useData()
   const dialog = useDialog()
   const dialog = useDialog()
@@ -1183,6 +1198,8 @@ export function UserMessageDisplay(props: {
 
 
   const attachments = createMemo(() => files().filter(attached))
   const attachments = createMemo(() => files().filter(attached))
 
 
+  const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
+
   const inlineFiles = createMemo(() => files().filter(inline))
   const inlineFiles = createMemo(() => files().filter(inline))
 
 
   const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
   const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
@@ -1239,35 +1256,59 @@ export function UserMessageDisplay(props: {
 
 
   return (
   return (
     <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
     <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
-      <Show when={attachments().length > 0}>
+      <Show when={attachments().length > 0 || messageComments().length > 0}>
         <div data-slot="user-message-attachments">
         <div data-slot="user-message-attachments">
+          <For each={messageComments()}>
+            {(comment) => (
+              <CommentCardV2
+                comment={comment.comment}
+                path={comment.path}
+                selection={comment.selection}
+                title={comment.comment}
+              />
+            )}
+          </For>
           <For each={attachments()}>
           <For each={attachments()}>
             {(file) => {
             {(file) => {
               const type = kind(file)
               const type = kind(file)
               const name = file.filename ?? i18n.t("ui.message.attachment.alt")
               const name = file.filename ?? i18n.t("ui.message.attachment.alt")
 
 
               return (
               return (
-                <div
-                  data-slot="user-message-attachment"
-                  data-type={type}
-                  data-clickable={type === "image" ? "true" : undefined}
-                  title={type === "file" ? name : undefined}
-                  onClick={() => {
-                    if (type === "image") openImagePreview(file.url, name)
-                  }}
+                <Show
+                  when={newLayout() && type === "file"}
+                  fallback={
+                    <div
+                      data-slot="user-message-attachment"
+                      data-type={type}
+                      data-clickable={type === "image" ? "true" : undefined}
+                      title={type === "file" ? name : undefined}
+                      onClick={() => {
+                        if (type === "image") openImagePreview(file.url, name)
+                      }}
+                    >
+                      <Show
+                        when={type === "image"}
+                        fallback={
+                          <div data-slot="user-message-attachment-file">
+                            <FileIcon node={{ path: name, type: "file" }} />
+                            <span data-slot="user-message-attachment-name">{name}</span>
+                          </div>
+                        }
+                      >
+                        <img data-slot="user-message-attachment-image" src={file.url} alt={name} />
+                      </Show>
+                    </div>
+                  }
                 >
                 >
-                  <Show
-                    when={type === "image"}
-                    fallback={
-                      <div data-slot="user-message-attachment-file">
-                        <FileIcon node={{ path: name, type: "file" }} />
-                        <span data-slot="user-message-attachment-name">{name}</span>
-                      </div>
-                    }
+                  <AttachmentCardV2
+                    title={getFilename(name)}
+                    hover={name}
+                    clickable={!!props.actions?.openAttachment}
+                    onClick={() => props.actions?.openAttachment?.(file)}
                   >
                   >
-                    <img data-slot="user-message-attachment-image" src={file.url} alt={name} />
-                  </Show>
-                </div>
+                    {typeLabel(name, file.mime)}
+                  </AttachmentCardV2>
+                </Show>
               )
               )
             }}
             }}
           </For>
           </For>

+ 58 - 0
packages/session-ui/src/v2/components/attachment-card-v2.css

@@ -0,0 +1,58 @@
+[data-component="attachment-card-v2"] {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  box-sizing: border-box;
+  width: 160px;
+  min-width: 160px;
+  max-width: 160px;
+  padding: 8px;
+  border-radius: 6px;
+  background: var(--v2-overlay-simple-overlay-hover);
+  box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
+  cursor: default;
+
+  &[data-active] {
+    box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
+  }
+
+  &[data-clickable] {
+    cursor: pointer;
+  }
+
+  [data-slot="attachment-card-v2-title"],
+  [data-slot="attachment-card-v2-subtitle"] {
+    max-width: 100%;
+    font-family: var(--v2-font-family-sans, "Inter", sans-serif);
+    font-style: normal;
+    font-size: 11px;
+    line-height: 12px;
+    letter-spacing: 0.05px;
+    font-variation-settings: "slnt" 0;
+  }
+
+  [data-slot="attachment-card-v2-title"] {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-weight: 530;
+    color: var(--v2-text-text-base);
+  }
+
+  [data-slot="attachment-card-v2-subtitle"] {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    min-width: 0;
+    overflow: hidden;
+    white-space: nowrap;
+    font-weight: 440;
+    color: var(--v2-text-text-muted);
+
+    [data-component="file-icon"] {
+      width: 12px;
+      height: 12px;
+      flex: none;
+    }
+  }
+}

+ 26 - 0
packages/session-ui/src/v2/components/attachment-card-v2.tsx

@@ -0,0 +1,26 @@
+import type { JSX } from "solid-js"
+import "./attachment-card-v2.css"
+
+/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */
+export function AttachmentCardV2(props: {
+  title: string
+  active?: boolean
+  clickable?: boolean
+  /** native title attribute */
+  hover?: string
+  onClick?: () => void
+  children: JSX.Element
+}) {
+  return (
+    <div
+      data-component="attachment-card-v2"
+      data-active={props.active ? "true" : undefined}
+      data-clickable={props.clickable ? "true" : undefined}
+      title={props.hover}
+      onClick={() => props.onClick?.()}
+    >
+      <span data-slot="attachment-card-v2-title">{props.title}</span>
+      <span data-slot="attachment-card-v2-subtitle">{props.children}</span>
+    </div>
+  )
+}

+ 27 - 0
packages/session-ui/src/v2/components/comment-card-v2.tsx

@@ -0,0 +1,27 @@
+import { Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { getFilenameTruncated } from "@opencode-ai/core/util/path"
+import { AttachmentCardV2 } from "./attachment-card-v2"
+
+export function CommentCardV2(props: {
+  comment: string
+  path: string
+  selection?: { startLine: number; endLine: number }
+  active?: boolean
+  title?: string
+  onClick?: () => void
+}) {
+  return (
+    <AttachmentCardV2 title={props.comment} active={props.active} hover={props.title} onClick={props.onClick}>
+      <FileIcon node={{ path: props.path, type: "file" }} />
+      <span>
+        {getFilenameTruncated(props.path, 14)}
+        <Show when={props.selection}>
+          {(sel) =>
+            sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
+          }
+        </Show>
+      </span>
+    </AttachmentCardV2>
+  )
+}

+ 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 { useI18n } from "@opencode-ai/ui/context/i18n"
 import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
 import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
 import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2"
 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> = {
 type LineCommentControllerV2Props<T extends LineCommentShape> = {
   comments: Accessor<T[]>
   comments: Accessor<T[]>
@@ -23,6 +24,7 @@ type LineCommentControllerV2Props<T extends LineCommentShape> = {
   onDelete?: (comment: T) => void
   onDelete?: (comment: T) => void
   renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
   renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
   editSubmitLabel?: string
   editSubmitLabel?: string
+  mention?: LineCommentEditorV2Mention
 }
 }
 
 
 type CommentProps = {
 type CommentProps = {
@@ -43,6 +45,7 @@ type DraftProps = {
   onSubmit: (value: string) => void
   onSubmit: (value: string) => void
   cancelLabel?: string
   cancelLabel?: string
   submitLabel?: string
   submitLabel?: string
+  mention?: LineCommentEditorV2Mention
 }
 }
 
 
 function lineCommentElementV2(view: Accessor<CommentProps>) {
 function lineCommentElementV2(view: Accessor<CommentProps>) {
@@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
           onSubmit={view().editor!.onSubmit}
           onSubmit={view().editor!.onSubmit}
           cancelLabel={view().editor!.cancelLabel}
           cancelLabel={view().editor!.cancelLabel}
           submitLabel={view().editor!.submitLabel}
           submitLabel={view().editor!.submitLabel}
+          mention={view().editor!.mention}
         />
         />
       </div>
       </div>
     </Show>
     </Show>
@@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
         onSubmit={view().onSubmit}
         onSubmit={view().onSubmit}
         cancelLabel={view().cancelLabel}
         cancelLabel={view().cancelLabel}
         submitLabel={view().submitLabel}
         submitLabel={view().submitLabel}
+        mention={view().mention}
       />
       />
     </div>
     </div>
   )
   )
@@ -142,6 +147,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
                 },
                 },
                 cancelLabel: i18n.t("ui.lineComment.cancel"),
                 cancelLabel: i18n.t("ui.lineComment.cancel"),
                 submitLabel: props.editSubmitLabel,
                 submitLabel: props.editSubmitLabel,
+                mention: props.mention,
               }
               }
             : undefined
             : undefined
         },
         },
@@ -165,6 +171,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
       },
       },
       cancelLabel: i18n.t("ui.lineComment.cancel"),
       cancelLabel: i18n.t("ui.lineComment.cancel"),
       submitLabel: i18n.t("ui.lineComment.submit"),
       submitLabel: i18n.t("ui.lineComment.submit"),
+      mention: props.mention,
     }),
     }),
   })
   })
 
 
@@ -202,6 +209,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
   }
   }
 
 
   return {
   return {
+    note,
     annotations,
     annotations,
     renderAnnotation,
     renderAnnotation,
     renderGutterUtility,
     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));
   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-component="session-review-v2-sidebar-root"]
   [data-slot="session-review-v2-sidebar-title"]
   [data-slot="session-review-v2-sidebar-title"]
   [data-component="select-v2-root"] {
   [data-component="select-v2-root"] {
@@ -97,7 +102,7 @@
   width: 100%;
   width: 100%;
   max-width: 100%;
   max-width: 100%;
   min-width: 0;
   min-width: 0;
-  margin-top: 2px;
+  margin-top: 3px;
 }
 }
 
 
 [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
 [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
   empty?: JSX.Element
   sidebarOpen?: boolean
   sidebarOpen?: boolean
   sidebar?: JSX.Element
   sidebar?: JSX.Element
-  sidebarToggle?: JSX.Element
   activeFile?: string
   activeFile?: string
   files: string[]
   files: string[]
   onSelectFile: (file: string) => void
   onSelectFile: (file: string) => void
@@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
 
 
   const toolbarStart = () => (
   const toolbarStart = () => (
     <>
     <>
-      {props.sidebarToggle}
       <Show when={showCollapsedMeta()}>
       <Show when={showCollapsedMeta()}>
         <div data-slot="session-review-v2-toolbar-collapsed-meta">
         <div data-slot="session-review-v2-toolbar-collapsed-meta">
           <Show when={title()}>
           <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()
   const i18n = useI18n()
 
 
   return (
   return (
@@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle:
         class="session-review-v2-sidebar-toggle"
         class="session-review-v2-sidebar-toggle"
         aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
         aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
         aria-expanded={props.opened}
         aria-expanded={props.opened}
+        disabled={props.disabled}
         onClick={props.onToggle}
         onClick={props.onToggle}
         icon={<Icon name="filetree" />}
         icon={<Icon name="filetree" />}
       />
       />

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/slack",
   "name": "@opencode-ai/slack",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 2 - 1
packages/stats/app/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/stats-app",
   "name": "@opencode-ai/stats-app",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
@@ -13,6 +13,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@ibm/plex": "6.4.1",
     "@ibm/plex": "6.4.1",
+    "@kobalte/core": "catalog:",
     "@opencode-ai/stats-core": "workspace:*",
     "@opencode-ai/stats-core": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@solidjs/meta": "catalog:",
     "@solidjs/meta": "catalog:",

+ 27 - 41
packages/stats/app/src/routes/[lab]/[model].tsx

@@ -39,6 +39,7 @@ import {
   uniqueComparisonPairs,
   uniqueComparisonPairs,
   type ComparisonModelRef,
   type ComparisonModelRef,
 } from "../compare-cards"
 } from "../compare-cards"
+import { BreadcrumbSelect } from "../breadcrumb-select"
 import {
 import {
   applyThemePreference,
   applyThemePreference,
   Footer,
   Footer,
@@ -294,61 +295,46 @@ function ModelHero(props: {
         <Show
         <Show
           when={labs().length > 0}
           when={labs().length > 0}
           fallback={
           fallback={
-            <span data-slot="model-hero-crumb" data-current="true">
+            <span data-slot="model-hero-crumb" data-menu="true">
               <span>{props.labName}</span>
               <span>{props.labName}</span>
               <ChevronDownIcon />
               <ChevronDownIcon />
             </span>
             </span>
           }
           }
         >
         >
-          <details data-component="model-hero-menu">
-            <summary data-slot="model-hero-crumb" data-current="true">
-              <span>{props.labName}</span>
-              <ChevronDownIcon />
-            </summary>
-            <div data-slot="model-hero-options">
-              <For each={labs()}>
-                {(lab) => (
-                  <a
-                    data-slot="model-hero-option"
-                    data-current={lab.id === providerSlug(labId()) ? "true" : undefined}
-                    href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
-                  >
-                    {lab.name}
-                  </a>
-                )}
-              </For>
-            </div>
-          </details>
+          <BreadcrumbSelect
+            ariaLabel="Choose a lab"
+            label={props.labName}
+            options={labs().map((lab) => ({
+              href: language.route(`${import.meta.env.BASE_URL}${lab.id}`),
+              label: lab.name,
+              value: lab.id,
+            }))}
+            value={providerSlug(labId())}
+            variant="model"
+          />
         </Show>
         </Show>
         <span data-slot="model-hero-separator">/</span>
         <span data-slot="model-hero-separator">/</span>
         <Show
         <Show
           when={labModels().length > 0}
           when={labModels().length > 0}
           fallback={
           fallback={
-            <span data-slot="model-hero-crumb" data-current="true" aria-current="page">
+            <span data-slot="model-hero-crumb" data-menu="true" data-current="true" aria-current="page">
               <span>{modelName()}</span>
               <span>{modelName()}</span>
               <ChevronDownIcon />
               <ChevronDownIcon />
             </span>
             </span>
           }
           }
         >
         >
-          <details data-component="model-hero-menu">
-            <summary data-slot="model-hero-crumb" data-current="true" aria-current="page">
-              <span>{modelName()}</span>
-              <ChevronDownIcon />
-            </summary>
-            <div data-slot="model-hero-options">
-              <For each={labModels()}>
-                {(model) => (
-                  <a
-                    data-slot="model-hero-option"
-                    data-current={model.id === props.catalog?.id ? "true" : undefined}
-                    href={language.route(`${import.meta.env.BASE_URL}${model.id}`)}
-                  >
-                    {model.name}
-                  </a>
-                )}
-              </For>
-            </div>
-          </details>
+          <BreadcrumbSelect
+            ariaLabel="Choose a model"
+            current
+            label={modelName()}
+            options={labModels().map((model) => ({
+              href: language.route(`${import.meta.env.BASE_URL}${model.id}`),
+              label: model.name,
+              value: model.id,
+            }))}
+            value={props.catalog?.id ?? ""}
+            variant="model"
+          />
         </Show>
         </Show>
       </nav>
       </nav>
       <div data-slot="model-hero-title-row">
       <div data-slot="model-hero-title-row">
@@ -432,7 +418,7 @@ function ModelHeroSparkline(props: { data: StatsModelData }) {
 function ChevronDownIcon() {
 function ChevronDownIcon() {
   return (
   return (
     <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
     <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
-      <path d="M4.75 6.25L8 9.5L11.25 6.25" stroke="currentColor" stroke-width="1.5" />
+      <path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor" />
     </svg>
     </svg>
   )
   )
 }
 }

+ 15 - 22
packages/stats/app/src/routes/[lab]/index.tsx

@@ -29,6 +29,7 @@ import { SectionHeading } from "../section-heading"
 import { runStatsEffect } from "../../stats-runtime"
 import { runStatsEffect } from "../../stats-runtime"
 import { setStatsPageCacheHeaders } from "../stats-cache"
 import { setStatsPageCacheHeaders } from "../stats-cache"
 import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
 import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
+import { BreadcrumbSelect } from "../breadcrumb-select"
 import {
 import {
   applyThemePreference,
   applyThemePreference,
   Footer,
   Footer,
@@ -205,6 +206,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
 function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
 function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
   const language = useLanguage()
   const language = useLanguage()
   const labs = () => props.labs ?? []
   const labs = () => props.labs ?? []
+  const current = () => labs().find((lab) => lab.name === props.label)
   return (
   return (
     <nav data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
     <nav data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
       <a data-slot="lab-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
       <a data-slot="lab-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
@@ -217,32 +219,23 @@ function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
           <span data-slot="lab-hero-crumb" data-current="true" aria-current="page">
           <span data-slot="lab-hero-crumb" data-current="true" aria-current="page">
             <span>{props.label}</span>
             <span>{props.label}</span>
             <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
             <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
-              <path d="M4.75 6.25L8 9.5L11.25 6.25" fill="none" stroke="currentColor" stroke-width="1.5" />
+              <path d="M5 6.5L8 9.5L11 6.5" fill="none" stroke="currentColor" />
             </svg>
             </svg>
           </span>
           </span>
         }
         }
       >
       >
-        <details data-component="lab-hero-menu">
-          <summary data-slot="lab-hero-crumb" data-current="true" aria-current="page">
-            <span>{props.label}</span>
-            <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
-              <path d="M4.75 6.25L8 9.5L11.25 6.25" fill="none" stroke="currentColor" stroke-width="1.5" />
-            </svg>
-          </summary>
-          <div data-slot="lab-hero-options">
-            <For each={labs()}>
-              {(lab) => (
-                <a
-                  data-slot="lab-hero-option"
-                  data-current={lab.name === props.label ? "true" : undefined}
-                  href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
-                >
-                  {lab.name}
-                </a>
-              )}
-            </For>
-          </div>
-        </details>
+        <BreadcrumbSelect
+          ariaLabel="Choose a lab"
+          current
+          label={props.label}
+          options={labs().map((lab) => ({
+            href: language.route(`${import.meta.env.BASE_URL}${lab.id}`),
+            label: lab.name,
+            value: lab.id,
+          }))}
+          value={current()?.id ?? ""}
+          variant="lab"
+        />
       </Show>
       </Show>
     </nav>
     </nav>
   )
   )

+ 71 - 0
packages/stats/app/src/routes/breadcrumb-select.tsx

@@ -0,0 +1,71 @@
+import { Select } from "@kobalte/core/select"
+import { createMemo } from "solid-js"
+
+export type BreadcrumbSelectOption = {
+  href: string
+  label: string
+  value: string
+}
+
+export function BreadcrumbSelect(props: {
+  ariaLabel: string
+  current?: boolean
+  label: string
+  options: BreadcrumbSelectOption[]
+  value: string
+  variant: "lab" | "model"
+}) {
+  const selected = createMemo(() => props.options.find((option) => option.value === props.value) ?? null)
+
+  return (
+    <Select<BreadcrumbSelectOption>
+      data-component="stats-breadcrumb-select"
+      data-variant={props.variant}
+      options={props.options}
+      optionValue="value"
+      optionTextValue="label"
+      value={selected()}
+      placeholder={props.label}
+      disallowEmptySelection
+      closeOnSelection
+      shouldFocusWrap
+      placement="bottom"
+      gutter={8}
+      sameWidth={false}
+      itemComponent={(itemProps) => (
+        <Select.Item
+          item={itemProps.item}
+          data-slot="stats-breadcrumb-select-option"
+          data-current={itemProps.item.rawValue.value === props.value ? "true" : undefined}
+        >
+          <Select.ItemLabel>{itemProps.item.rawValue.label}</Select.ItemLabel>
+        </Select.Item>
+      )}
+      onChange={(option) => {
+        if (!option || option.value === props.value) return
+        window.location.assign(option.href)
+      }}
+    >
+      <Select.Trigger
+        data-slot="stats-breadcrumb-select-trigger"
+        data-current={props.current ? "true" : undefined}
+        data-variant={props.variant}
+        aria-label={props.ariaLabel}
+        aria-current={props.current ? "page" : undefined}
+      >
+        <Select.Value<BreadcrumbSelectOption> data-slot="stats-breadcrumb-select-value">
+          {(state) => <span>{state.selectedOption()?.label ?? props.label}</span>}
+        </Select.Value>
+        <Select.Icon data-slot="stats-breadcrumb-select-icon">
+          <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
+            <path d="M5 6.5L8 9.5L11 6.5" fill="none" stroke="currentColor" />
+          </svg>
+        </Select.Icon>
+      </Select.Trigger>
+      <Select.HiddenSelect />
+      <Select.Content data-component="stats-breadcrumb-select-content" data-variant={props.variant}>
+        <Select.Listbox data-slot="stats-breadcrumb-select-listbox" />
+      </Select.Content>
+    </Select>
+  )
+}

+ 1 - 1
packages/stats/app/src/routes/compare/index.tsx

@@ -134,7 +134,7 @@ export default function ModelCompareIndex() {
                 Data
                 Data
               </a>
               </a>
               <span data-slot="compare-home-separator">/</span>
               <span data-slot="compare-home-separator">/</span>
-              <span data-slot="compare-home-crumb" data-current="true">
+              <span data-slot="compare-home-crumb" data-current="true" aria-current="page">
                 Compare
                 Compare
               </span>
               </span>
             </nav>
             </nav>

+ 117 - 109
packages/stats/app/src/routes/index.css

@@ -2888,7 +2888,7 @@
   padding: 0 8px;
   padding: 0 8px;
   overflow: hidden;
   overflow: hidden;
   background: var(--stats-layer-2);
   background: var(--stats-layer-2);
-  color: var(--stats-faint);
+  color: var(--stats-hero-muted);
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
   line-height: 1.1;
   line-height: 1.1;
@@ -2897,21 +2897,7 @@
 }
 }
 
 
 [data-page="stats"] a[data-slot="lab-hero-crumb"] {
 [data-page="stats"] a[data-slot="lab-hero-crumb"] {
-  color: var(--stats-faint);
-}
-
-[data-page="stats"] [data-component="lab-hero-menu"] {
-  position: relative;
-  display: inline-block;
-}
-
-[data-page="stats"] [data-component="lab-hero-menu"] summary {
-  cursor: pointer;
-  list-style: none;
-}
-
-[data-page="stats"] [data-component="lab-hero-menu"] summary::-webkit-details-marker {
-  display: none;
+  color: var(--stats-hero-muted);
 }
 }
 
 
 [data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] {
 [data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] {
@@ -2920,9 +2906,7 @@
 }
 }
 
 
 [data-page="stats"] a[data-slot="lab-hero-crumb"]:hover,
 [data-page="stats"] a[data-slot="lab-hero-crumb"]:hover,
-[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible,
-[data-page="stats"] summary[data-slot="lab-hero-crumb"]:hover,
-[data-page="stats"] summary[data-slot="lab-hero-crumb"]:focus-visible {
+[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible {
   color: var(--stats-text);
   color: var(--stats-text);
   outline: none;
   outline: none;
   text-decoration: none;
   text-decoration: none;
@@ -2941,57 +2925,6 @@
   color: currentColor;
   color: currentColor;
 }
 }
 
 
-[data-page="stats"] [data-component="lab-hero-menu"][open] [data-slot="lab-hero-crumb"] svg {
-  transform: rotate(180deg);
-}
-
-[data-page="stats"] [data-slot="lab-hero-options"] {
-  position: absolute;
-  top: calc(100% + 8px);
-  left: 50%;
-  z-index: 5;
-  display: grid;
-  width: max-content;
-  min-width: 180px;
-  max-width: min(280px, calc(100vw - 48px));
-  max-height: min(360px, calc(100vh - 160px));
-  padding: 6px;
-  overflow: auto;
-  border: 1px solid var(--stats-line);
-  background: var(--stats-bg);
-  box-shadow: 0 12px 32px #0000001a;
-  transform: translateX(-50%);
-}
-
-[data-page="stats"] [data-slot="lab-hero-option"] {
-  display: flex;
-  align-items: center;
-  min-width: 0;
-  height: 30px;
-  padding: 0 8px;
-  overflow: hidden;
-  color: var(--stats-muted);
-  font-size: 13px;
-  font-weight: 500;
-  line-height: 1.2;
-  text-decoration: none;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-[data-page="stats"] [data-slot="lab-hero-option"]:hover,
-[data-page="stats"] [data-slot="lab-hero-option"]:focus-visible {
-  background: var(--stats-layer-2);
-  color: var(--stats-text);
-  outline: none;
-  text-decoration: none;
-}
-
-[data-page="stats"] [data-slot="lab-hero-option"][data-current="true"] {
-  background: var(--stats-layer-2);
-  color: var(--stats-text);
-}
-
 [data-page="stats"] [data-slot="lab-hero-separator"] {
 [data-page="stats"] [data-slot="lab-hero-separator"] {
   display: inline-flex;
   display: inline-flex;
   align-items: center;
   align-items: center;
@@ -3113,96 +3046,163 @@
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
+[data-page="stats"] a[data-slot="model-hero-crumb"] {
+  color: var(--stats-hero-muted);
+}
+
+[data-page="stats"] [data-slot="model-hero-crumb"][data-menu="true"] {
   padding-right: 4px;
   padding-right: 4px;
 }
 }
 
 
-[data-page="stats"] [data-component="model-hero-menu"] {
-  position: relative;
-  display: inline-block;
+[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
+  color: var(--stats-text);
+}
+
+[data-page="stats"] [data-component="stats-breadcrumb-select"] {
+  display: inline-flex;
   min-width: 0;
   min-width: 0;
 }
 }
 
 
-[data-page="stats"] [data-component="model-hero-menu"] summary {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"] {
+  display: inline-flex;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 4px;
+  flex: 0 1 auto;
+  max-width: min(100%, 280px);
+  height: 24px;
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0 4px 0 8px;
+  overflow: hidden;
+  border: 0;
+  border-radius: 0;
+  appearance: none;
+  background: var(--stats-layer-2);
+  color: var(--stats-hero-muted);
+  font: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 1.1;
+  text-align: center;
+  white-space: nowrap;
   cursor: pointer;
   cursor: pointer;
-  list-style: none;
 }
 }
 
 
-[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker {
-  display: none;
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"],
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:hover,
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded] {
+  color: var(--stats-text);
 }
 }
 
 
-[data-page="stats"] a[data-slot="model-hero-crumb"]:hover,
-[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible,
-[data-page="stats"] summary[data-slot="model-hero-crumb"]:hover,
-[data-page="stats"] summary[data-slot="model-hero-crumb"]:focus-visible {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:focus-visible {
   color: var(--stats-text);
   color: var(--stats-text);
-  outline: none;
-  text-decoration: none;
+  outline: 1px solid var(--stats-text);
+  outline-offset: 2px;
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-crumb"] span {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] {
   min-width: 0;
   min-width: 0;
   overflow: hidden;
   overflow: hidden;
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] span {
+  display: block;
+  overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-crumb"] svg {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] {
+  display: inline-flex;
   flex: 0 0 auto;
   flex: 0 0 auto;
+  color: var(--stats-faint);
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] svg {
   width: 16px;
   width: 16px;
   height: 16px;
   height: 16px;
-  color: var(--stats-faint);
+  color: currentColor;
 }
 }
 
 
-[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg {
+[data-page="stats"]
+  [data-slot="stats-breadcrumb-select-trigger"][data-expanded]
+  [data-slot="stats-breadcrumb-select-icon"] {
   transform: rotate(180deg);
   transform: rotate(180deg);
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-options"] {
-  position: absolute;
-  top: calc(100% + 8px);
-  left: 50%;
-  z-index: 5;
-  display: grid;
+[data-page="stats"] [data-popper-positioner]:has([data-component="stats-breadcrumb-select-content"]) {
+  z-index: 20;
+}
+
+[data-page="stats"] [data-component="stats-breadcrumb-select-content"] {
+  z-index: 20;
   width: max-content;
   width: max-content;
   min-width: 180px;
   min-width: 180px;
   max-width: min(360px, calc(100vw - 48px));
   max-width: min(360px, calc(100vw - 48px));
-  max-height: min(360px, calc(100vh - 160px));
-  padding: 6px;
-  overflow: auto;
+  overflow: hidden;
   border: 1px solid var(--stats-line);
   border: 1px solid var(--stats-line);
   background: var(--stats-bg);
   background: var(--stats-bg);
   box-shadow: 0 12px 32px #0000001a;
   box-shadow: 0 12px 32px #0000001a;
-  transform: translateX(-50%);
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-option"] {
+[data-page="stats"] [data-component="stats-breadcrumb-select-content"][data-variant="lab"] {
+  max-width: min(280px, calc(100vw - 48px));
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-listbox"] {
+  display: grid;
+  max-height: min(360px, calc(100vh - 160px));
+  box-sizing: border-box;
+  margin: 0;
+  padding: 6px;
+  overflow: auto;
+  list-style: none;
+  outline: none;
+}
+
+[data-page="stats"] [data-slot="stats-breadcrumb-select-option"] {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   min-width: 0;
   min-width: 0;
   height: 30px;
   height: 30px;
+  box-sizing: border-box;
   padding: 0 8px;
   padding: 0 8px;
   overflow: hidden;
   overflow: hidden;
   color: var(--stats-muted);
   color: var(--stats-muted);
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
   line-height: 1.2;
   line-height: 1.2;
-  text-decoration: none;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
+  cursor: pointer;
+  outline: none;
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-option"]:hover,
-[data-page="stats"] [data-slot="model-hero-option"]:focus-visible {
+[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-highlighted],
+[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-selected] {
   background: var(--stats-layer-2);
   background: var(--stats-layer-2);
   color: var(--stats-text);
   color: var(--stats-text);
+}
+
+[data-page="stats"] a[data-slot="model-hero-crumb"]:hover,
+[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible {
+  color: var(--stats-text);
   outline: none;
   outline: none;
   text-decoration: none;
   text-decoration: none;
 }
 }
 
 
-[data-page="stats"] [data-slot="model-hero-option"][data-current="true"] {
-  background: var(--stats-layer-2);
-  color: var(--stats-text);
+[data-page="stats"] [data-slot="model-hero-crumb"] span {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+[data-page="stats"] [data-slot="model-hero-crumb"] svg {
+  flex: 0 0 auto;
+  width: 16px;
+  height: 16px;
+  color: currentColor;
 }
 }
 
 
 [data-page="stats"] [data-slot="model-hero-separator"] {
 [data-page="stats"] [data-slot="model-hero-separator"] {
@@ -5533,6 +5533,14 @@
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
+[data-page="stats"] a[data-slot="compare-home-crumb"] {
+  color: var(--stats-hero-muted);
+}
+
+[data-page="stats"] [data-component="compare-home-breadcrumb"] [data-slot="compare-home-crumb"][data-current="true"] {
+  color: var(--stats-text);
+}
+
 [data-page="stats"] a[data-slot="compare-home-crumb"]:hover,
 [data-page="stats"] a[data-slot="compare-home-crumb"]:hover,
 [data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible {
 [data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible {
   color: var(--stats-text);
   color: var(--stats-text);
@@ -7876,6 +7884,10 @@
     max-width: min(100%, 240px);
     max-width: min(100%, 240px);
   }
   }
 
 
+  [data-page="stats"] [data-component="model-hero-breadcrumb"] [data-slot="stats-breadcrumb-select-trigger"] {
+    max-width: min(100%, 240px);
+  }
+
   [data-page="stats"] [data-slot="model-hero-title-row"] {
   [data-page="stats"] [data-slot="model-hero-title-row"] {
     flex-wrap: wrap;
     flex-wrap: wrap;
     gap: 16px;
     gap: 16px;
@@ -7967,10 +7979,6 @@
     min-width: 48px;
     min-width: 48px;
   }
   }
 
 
-  [data-page="stats"] [data-slot="lab-hero-options"] {
-    max-width: calc(100vw - 48px);
-  }
-
   [data-page="stats"] [data-component="lab-related-list"] {
   [data-page="stats"] [data-component="lab-related-list"] {
     grid-auto-columns: minmax(320px, 392px);
     grid-auto-columns: minmax(320px, 392px);
     grid-auto-flow: column;
     grid-auto-flow: column;

+ 1 - 1
packages/stats/core/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/stats-core",
   "name": "@opencode-ai/stats-core",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/stats/server/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/stats-server",
   "name": "@opencode-ai/stats-server",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 4 - 0
packages/storybook/.storybook/mocks/app/context/prompt.ts

@@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) {
   return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
   return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
 }
 }
 
 
+export function isCommentItem(item: ContextItem) {
+  return !!item.comment?.trim()
+}
+
 export function createPromptState() {
 export function createPromptState() {
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     prompt: clonePrompt(DEFAULT_PROMPT),
     prompt: clonePrompt(DEFAULT_PROMPT),

+ 1 - 1
packages/tui/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/tui",
   "name": "@opencode-ai/tui",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/ui",
   "name": "@opencode-ai/ui",
-  "version": "1.17.18",
+  "version": "1.17.20",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "repository": {
   "repository": {

Некоторые файлы не были показаны из-за большого количества измененных файлов