Sfoglia il codice sorgente

chore: merge dev into sidebar hydration

LukeParkerDev 3 settimane fa
parent
commit
afa57bb862
100 ha cambiato i file con 8206 aggiunte e 2380 eliminazioni
  1. 5 1
      .github/TEAM_MEMBERS
  2. 1 1
      .opencode/command/translate.md
  3. 1 1
      .opencode/tool/github-triage.ts
  4. 1 1
      README.zh.md
  5. 1 1
      README.zht.md
  6. 10 0
      artifacts/glm52-rise-video/sst-env.d.ts
  7. 137 75
      bun.lock
  8. 4 0
      infra/lake.ts
  9. 19 0
      infra/stage.ts
  10. 3 1
      infra/stats.ts
  11. 66 33
      nix/desktop.nix
  12. 4 4
      nix/hashes.json
  13. 10 9
      package.json
  14. 3 0
      packages/app/e2e/performance/timeline-stability/fixture.ts
  15. 34 0
      packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts
  16. 312 0
      packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts
  17. 151 0
      packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts
  18. 4 1
      packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts
  19. 48 8
      packages/app/e2e/performance/timeline/session-tab-switch-probe.ts
  20. 46 0
      packages/app/e2e/performance/unit/mock-server.test.ts
  21. 32 0
      packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts
  22. 55 0
      packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts
  23. 150 0
      packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts
  24. 41 0
      packages/app/e2e/regression/legacy-new-session.spec.ts
  25. 3 6
      packages/app/e2e/regression/prompt-thinking-level.spec.ts
  26. 270 0
      packages/app/e2e/regression/remote-session-settings.spec.ts
  27. 163 0
      packages/app/e2e/regression/review-open-file.spec.ts
  28. 152 0
      packages/app/e2e/regression/review-state-persistence.spec.ts
  29. 29 11
      packages/app/e2e/regression/review-tab-switch.spec.ts
  30. 263 0
      packages/app/e2e/regression/review-terminal-stacked.spec.ts
  31. 62 0
      packages/app/e2e/regression/session-request-docks.spec.ts
  32. 242 0
      packages/app/e2e/regression/session-timeline-history-root.spec.ts
  33. 4 1
      packages/app/e2e/regression/session-timeline-projection.spec.ts
  34. 228 0
      packages/app/e2e/regression/session-timeline-shell-outline.spec.ts
  35. 122 2
      packages/app/e2e/regression/terminal-composer-focus.spec.ts
  36. 3 2
      packages/app/e2e/regression/terminal-hidden.spec.ts
  37. 34 0
      packages/app/e2e/reproduction/timeline-suspense/index.html
  38. 315 0
      packages/app/e2e/reproduction/timeline-suspense/main.tsx
  39. 34 0
      packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts
  40. 179 0
      packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts
  41. 7 0
      packages/app/e2e/reproduction/timeline-suspense/vite.config.ts
  42. 1 1
      packages/app/e2e/smoke/session-timeline.spec.ts
  43. 3 0
      packages/app/e2e/tsconfig.json
  44. 66 2
      packages/app/e2e/utils/mock-server.ts
  45. 1 1
      packages/app/e2e/utils/sse-transport.ts
  46. 3 1
      packages/app/package.json
  47. 148 75
      packages/app/src/app.tsx
  48. BIN
      packages/app/src/assets/help/home.png
  49. BIN
      packages/app/src/assets/help/introducing-tabs.mp4
  50. BIN
      packages/app/src/assets/help/placeholder.png
  51. BIN
      packages/app/src/assets/help/tabs.png
  52. 279 0
      packages/app/src/components/command-palette.ts
  53. 53 1
      packages/app/src/components/debug-bar.tsx
  54. 220 0
      packages/app/src/components/dialog-command-palette-v2.css
  55. 343 0
      packages/app/src/components/dialog-command-palette-v2.tsx
  56. 73 0
      packages/app/src/components/dialog-connect-provider.stories.tsx
  57. 612 90
      packages/app/src/components/dialog-connect-provider.tsx
  58. 165 166
      packages/app/src/components/dialog-custom-provider.tsx
  59. 156 0
      packages/app/src/components/dialog-edit-project-v2.tsx
  60. 38 135
      packages/app/src/components/dialog-edit-project.tsx
  61. 3 3
      packages/app/src/components/dialog-manage-models.tsx
  62. 65 368
      packages/app/src/components/dialog-select-file.tsx
  63. 55 0
      packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx
  64. 175 0
      packages/app/src/components/dialog-select-model-unpaid-v2.tsx
  65. 6 7
      packages/app/src/components/dialog-select-model-unpaid.tsx
  66. 40 23
      packages/app/src/components/dialog-select-model.tsx
  67. 0 87
      packages/app/src/components/dialog-select-provider.tsx
  68. 17 4
      packages/app/src/components/dialog-settings.tsx
  69. 119 0
      packages/app/src/components/edit-project.ts
  70. 74 0
      packages/app/src/components/file-tree-v2-model.test.ts
  71. 107 0
      packages/app/src/components/file-tree-v2-model.ts
  72. 169 291
      packages/app/src/components/file-tree-v2.tsx
  73. 3 0
      packages/app/src/components/file-tree.tsx
  74. 108 43
      packages/app/src/components/help-button.tsx
  75. 35 2
      packages/app/src/components/model-tooltip.tsx
  76. 591 0
      packages/app/src/components/prompt-input-v2.tsx
  77. 9 3
      packages/app/src/components/prompt-input.stories.tsx
  78. 362 813
      packages/app/src/components/prompt-input.tsx
  79. 1 1
      packages/app/src/components/prompt-input/attachments.ts
  80. 9 7
      packages/app/src/components/prompt-input/build-request-parts.ts
  81. 13 3
      packages/app/src/components/prompt-input/context-items.tsx
  82. 57 0
      packages/app/src/components/prompt-input/contracts.ts
  83. 47 0
      packages/app/src/components/prompt-input/history-store.ts
  84. 43 0
      packages/app/src/components/prompt-input/image-attachments.css
  85. 128 21
      packages/app/src/components/prompt-input/image-attachments.tsx
  86. 5 0
      packages/app/src/components/prompt-input/placeholder.ts
  87. 18 0
      packages/app/src/components/prompt-input/slash-popover.tsx
  88. 92 8
      packages/app/src/components/prompt-input/submit.test.ts
  89. 21 11
      packages/app/src/components/prompt-input/submit.ts
  90. 6 3
      packages/app/src/components/prompt-input/transient-state.ts
  91. 36 6
      packages/app/src/components/prompt-project-selector.tsx
  92. 29 9
      packages/app/src/components/prompt-workspace-selector.tsx
  93. 7 3
      packages/app/src/components/session-context-usage.tsx
  94. 1 0
      packages/app/src/components/session/index.ts
  95. 98 0
      packages/app/src/components/session/open-in-app-v2.tsx
  96. 229 0
      packages/app/src/components/session/open-in-app.tsx
  97. 5 14
      packages/app/src/components/session/session-context-metrics.test.ts
  98. 3 6
      packages/app/src/components/session/session-context-metrics.ts
  99. 7 7
      packages/app/src/components/session/session-context-tab.tsx
  100. 4 7
      packages/app/src/components/session/session-header.tsx

+ 5 - 1
.github/TEAM_MEMBERS

@@ -1,4 +1,5 @@
 adamdotdevin
 adamdotdevin
+arvsrn
 Brendonovich
 Brendonovich
 fwang
 fwang
 Hona
 Hona
@@ -7,11 +8,14 @@ jayair
 jlongster
 jlongster
 kitlangton
 kitlangton
 kommander
 kommander
+ludvigrask
 MrMushrooooom
 MrMushrooooom
 nexxeln
 nexxeln
 R44VC0RP
 R44VC0RP
 rekram1-node
 rekram1-node
 thdxr
 thdxr
 simonklee
 simonklee
+Slickstef11
+usrnk1
 vimtor
 vimtor
-starptech
+StarpTech

+ 1 - 1
.opencode/command/translate.md

@@ -1,6 +1,6 @@
 ---
 ---
 description: translate English to other languages
 description: translate English to other languages
-model: opencode/claude-opus-4-8
+model: opencode/gpt-5.6-sol
 ---
 ---
 
 
 run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
 run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.

+ 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

+ 1 - 1
README.zh.md

@@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
 
 
 ---
 ---
 
 
-**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)

+ 1 - 1
README.zht.md

@@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
 
 
 ---
 ---
 
 
-**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)

+ 10 - 0
artifacts/glm52-rise-video/sst-env.d.ts

@@ -0,0 +1,10 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+/* biome-ignore-all lint: auto-generated */
+
+/// <reference path="../../sst-env.d.ts" />
+
+import "sst"
+export {}

+ 137 - 75
bun.lock

@@ -29,8 +29,9 @@
     },
     },
     "packages/app": {
     "packages/app": {
       "name": "@opencode-ai/app",
       "name": "@opencode-ai/app",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
+        "@corvu/drawer": "catalog:",
         "@dnd-kit/abstract": "0.5.0",
         "@dnd-kit/abstract": "0.5.0",
         "@dnd-kit/dom": "0.5.0",
         "@dnd-kit/dom": "0.5.0",
         "@dnd-kit/helpers": "0.5.0",
         "@dnd-kit/helpers": "0.5.0",
@@ -72,6 +73,7 @@
         "shiki": "catalog:",
         "shiki": "catalog:",
         "solid-js": "catalog:",
         "solid-js": "catalog:",
         "solid-list": "catalog:",
         "solid-list": "catalog:",
+        "solid-presence": "0.2.0",
         "tailwindcss": "catalog:",
         "tailwindcss": "catalog:",
       },
       },
       "devDependencies": {
       "devDependencies": {
@@ -93,7 +95,7 @@
     },
     },
     "packages/cli": {
     "packages/cli": {
       "name": "@opencode-ai/cli",
       "name": "@opencode-ai/cli",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "bin": {
       "bin": {
         "lildax": "./bin/lildax.cjs",
         "lildax": "./bin/lildax.cjs",
       },
       },
@@ -141,7 +143,7 @@
     },
     },
     "packages/codemode": {
     "packages/codemode": {
       "name": "@opencode-ai/codemode",
       "name": "@opencode-ai/codemode",
-      "version": "0.0.1",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "acorn": "8.15.0",
         "acorn": "8.15.0",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -155,7 +157,7 @@
     },
     },
     "packages/console/app": {
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
       "name": "@opencode-ai/console-app",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
         "@ibm/plex": "6.4.1",
@@ -191,7 +193,7 @@
     },
     },
     "packages/console/core": {
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
       "name": "@opencode-ai/console-core",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "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",
@@ -218,7 +220,7 @@
     },
     },
     "packages/console/function": {
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
       "name": "@opencode-ai/console-function",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "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",
@@ -240,7 +242,7 @@
     },
     },
     "packages/console/mail": {
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
       "name": "@opencode-ai/console-mail",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "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",
@@ -264,7 +266,7 @@
     },
     },
     "packages/console/support": {
     "packages/console/support": {
       "name": "@opencode-ai/console-support",
       "name": "@opencode-ai/console-support",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@opencode-ai/console-core": "workspace:*",
         "@opencode-ai/console-core": "workspace:*",
@@ -284,7 +286,7 @@
     },
     },
     "packages/core": {
     "packages/core": {
       "name": "@opencode-ai/core",
       "name": "@opencode-ai/core",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -292,7 +294,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",
@@ -301,14 +303,14 @@
         "@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",
         "@ai-sdk/provider-utils": "4.0.23",
         "@ai-sdk/provider-utils": "4.0.23",
         "@ai-sdk/togetherai": "2.0.41",
         "@ai-sdk/togetherai": "2.0.41",
         "@ai-sdk/vercel": "2.0.39",
         "@ai-sdk/vercel": "2.0.39",
-        "@ai-sdk/xai": "3.0.82",
+        "@ai-sdk/xai": "3.0.102",
         "@aws-sdk/credential-providers": "3.1057.0",
         "@aws-sdk/credential-providers": "3.1057.0",
         "@effect/opentelemetry": "catalog:",
         "@effect/opentelemetry": "catalog:",
         "@effect/platform-node": "catalog:",
         "@effect/platform-node": "catalog:",
@@ -336,7 +338,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",
@@ -378,7 +380,7 @@
     },
     },
     "packages/desktop": {
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
       "name": "@opencode-ai/desktop",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@zip.js/zip.js": "2.7.62",
         "@zip.js/zip.js": "2.7.62",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -432,7 +434,7 @@
     },
     },
     "packages/effect-drizzle-sqlite": {
     "packages/effect-drizzle-sqlite": {
       "name": "@opencode-ai/effect-drizzle-sqlite",
       "name": "@opencode-ai/effect-drizzle-sqlite",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "drizzle-orm": "catalog:",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -446,7 +448,7 @@
     },
     },
     "packages/effect-sqlite-node": {
     "packages/effect-sqlite-node": {
       "name": "@opencode-ai/effect-sqlite-node",
       "name": "@opencode-ai/effect-sqlite-node",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "effect": "catalog:",
         "effect": "catalog:",
       },
       },
@@ -458,7 +460,7 @@
     },
     },
     "packages/enterprise": {
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
       "name": "@opencode-ai/enterprise",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@hono/standard-validator": "catalog:",
         "@hono/standard-validator": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
@@ -490,7 +492,7 @@
     },
     },
     "packages/function": {
     "packages/function": {
       "name": "@opencode-ai/function",
       "name": "@opencode-ai/function",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
         "@octokit/rest": "catalog:",
@@ -506,7 +508,7 @@
     },
     },
     "packages/http-recorder": {
     "packages/http-recorder": {
       "name": "@opencode-ai/http-recorder",
       "name": "@opencode-ai/http-recorder",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@effect/platform-node": "4.0.0-beta.83",
         "@effect/platform-node": "4.0.0-beta.83",
         "@effect/platform-node-shared": "4.0.0-beta.83",
         "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -537,7 +539,7 @@
     },
     },
     "packages/llm": {
     "packages/llm": {
       "name": "@opencode-ai/llm",
       "name": "@opencode-ai/llm",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@smithy/eventstream-codec": "4.2.14",
         "@smithy/eventstream-codec": "4.2.14",
@@ -556,7 +558,7 @@
     },
     },
     "packages/opencode": {
     "packages/opencode": {
       "name": "opencode",
       "name": "opencode",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -567,7 +569,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",
@@ -576,13 +578,13 @@
         "@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",
         "@ai-sdk/togetherai": "2.0.41",
         "@ai-sdk/togetherai": "2.0.41",
         "@ai-sdk/vercel": "2.0.39",
         "@ai-sdk/vercel": "2.0.39",
-        "@ai-sdk/xai": "3.0.82",
+        "@ai-sdk/xai": "3.0.102",
         "@aws-sdk/credential-providers": "3.1057.0",
         "@aws-sdk/credential-providers": "3.1057.0",
         "@clack/prompts": "1.0.0-alpha.1",
         "@clack/prompts": "1.0.0-alpha.1",
         "@effect/opentelemetry": "catalog:",
         "@effect/opentelemetry": "catalog:",
@@ -629,7 +631,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",
@@ -687,7 +689,7 @@
     },
     },
     "packages/plugin": {
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
       "name": "@opencode-ai/plugin",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/provider": "3.0.8",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -704,9 +706,9 @@
         "typescript": "catalog:",
         "typescript": "catalog:",
       },
       },
       "peerDependencies": {
       "peerDependencies": {
-        "@opentui/core": ">=0.3.4",
-        "@opentui/keymap": ">=0.3.4",
-        "@opentui/solid": ">=0.3.4",
+        "@opentui/core": ">=0.4.5",
+        "@opentui/keymap": ">=0.4.5",
+        "@opentui/solid": ">=0.4.5",
       },
       },
       "optionalPeers": [
       "optionalPeers": [
         "@opentui/core",
         "@opentui/core",
@@ -763,7 +765,7 @@
     },
     },
     "packages/sdk/js": {
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "name": "@opencode-ai/sdk",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "cross-spawn": "catalog:",
         "cross-spawn": "catalog:",
       },
       },
@@ -778,7 +780,7 @@
     },
     },
     "packages/server": {
     "packages/server": {
       "name": "@opencode-ai/server",
       "name": "@opencode-ai/server",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/protocol": "workspace:*",
         "@opencode-ai/protocol": "workspace:*",
@@ -793,7 +795,7 @@
     },
     },
     "packages/session-ui": {
     "packages/session-ui": {
       "name": "@opencode-ai/session-ui",
       "name": "@opencode-ai/session-ui",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
@@ -837,7 +839,7 @@
     },
     },
     "packages/slack": {
     "packages/slack": {
       "name": "@opencode-ai/slack",
       "name": "@opencode-ai/slack",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
         "@slack/bolt": "^3.17.1",
@@ -850,9 +852,10 @@
     },
     },
     "packages/stats/app": {
     "packages/stats/app": {
       "name": "@opencode-ai/stats-app",
       "name": "@opencode-ai/stats-app",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "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:",
@@ -883,7 +886,7 @@
     },
     },
     "packages/stats/core": {
     "packages/stats/core": {
       "name": "@opencode-ai/stats-core",
       "name": "@opencode-ai/stats-core",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "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",
@@ -902,7 +905,7 @@
     },
     },
     "packages/stats/server": {
     "packages/stats/server": {
       "name": "@opencode-ai/stats-server",
       "name": "@opencode-ai/stats-server",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-firehose": "3.933.0",
         "@aws-sdk/client-firehose": "3.933.0",
         "@effect/platform-node": "catalog:",
         "@effect/platform-node": "catalog:",
@@ -934,6 +937,7 @@
         "@types/node": "catalog:",
         "@types/node": "catalog:",
         "@types/react": "18.0.25",
         "@types/react": "18.0.25",
         "react": "18.2.0",
         "react": "18.2.0",
+        "react-dom": "18.2.0",
         "solid-js": "catalog:",
         "solid-js": "catalog:",
         "storybook": "^10.2.13",
         "storybook": "^10.2.13",
         "storybook-solidjs-vite": "^10.0.9",
         "storybook-solidjs-vite": "^10.0.9",
@@ -943,7 +947,7 @@
     },
     },
     "packages/tui": {
     "packages/tui": {
       "name": "@opencode-ai/tui",
       "name": "@opencode-ai/tui",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
@@ -970,7 +974,7 @@
     },
     },
     "packages/ui": {
     "packages/ui": {
       "name": "@opencode-ai/ui",
       "name": "@opencode-ai/ui",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@pierre/diffs": "catalog:",
         "@pierre/diffs": "catalog:",
@@ -1021,7 +1025,7 @@
     },
     },
     "packages/web": {
     "packages/web": {
       "name": "@opencode-ai/web",
       "name": "@opencode-ai/web",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
         "@astrojs/markdown-remark": "6.3.1",
@@ -1064,17 +1068,16 @@
   ],
   ],
   "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",
     "@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",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.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",
     "@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",
-    "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.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": {
@@ -1086,6 +1089,7 @@
   },
   },
   "catalog": {
   "catalog": {
     "@cloudflare/workers-types": "4.20251008.0",
     "@cloudflare/workers-types": "4.20251008.0",
+    "@corvu/drawer": "0.2.4",
     "@effect/opentelemetry": "4.0.0-beta.83",
     "@effect/opentelemetry": "4.0.0-beta.83",
     "@effect/platform-node": "4.0.0-beta.83",
     "@effect/platform-node": "4.0.0-beta.83",
     "@effect/sql-sqlite-bun": "4.0.0-beta.83",
     "@effect/sql-sqlite-bun": "4.0.0-beta.83",
@@ -1096,9 +1100,9 @@
     "@npmcli/arborist": "9.4.0",
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@opentui/core": "0.3.4",
-    "@opentui/keymap": "0.3.4",
-    "@opentui/solid": "0.3.4",
+    "@opentui/core": "0.4.5",
+    "@opentui/keymap": "0.4.5",
+    "@opentui/solid": "0.4.5",
     "@pierre/diffs": "1.2.10",
     "@pierre/diffs": "1.2.10",
     "@playwright/test": "1.59.1",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
     "@sentry/solid": "10.36.0",
@@ -1109,7 +1113,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",
@@ -1129,7 +1133,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",
@@ -1171,7 +1175,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=="],
 
 
@@ -1181,7 +1185,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=="],
 
 
@@ -1211,7 +1215,7 @@
 
 
     "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "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-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
     "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "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-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
 
 
-    "@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-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
 
 
     "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
     "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
 
 
@@ -1467,6 +1471,10 @@
 
 
     "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
     "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
 
 
+    "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
+
+    "@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
+
     "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
     "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
 
 
     "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
     "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -2031,27 +2039,27 @@
 
 
     "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
     "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
 
 
-    "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
+    "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
 
 
-    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
+    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
 
 
-    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
+    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
 
 
-    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
+    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
 
 
-    "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
+    "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
 
 
-    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
+    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
 
 
-    "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
+    "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
 
 
-    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
+    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
 
 
-    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
+    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
 
 
-    "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
+    "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
 
 
-    "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
+    "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
 
 
     "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
     "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 
 
@@ -2667,6 +2675,8 @@
 
 
     "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
     "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
 
 
+    "@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="],
+
     "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
     "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
 
 
     "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
     "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
@@ -2771,9 +2781,9 @@
 
 
     "@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/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=="],
 
 
     "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
     "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
 
 
@@ -3243,7 +3253,7 @@
 
 
     "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
     "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
 
 
-    "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
+    "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
 
 
     "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
     "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
 
 
@@ -3841,7 +3851,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=="],
 
 
@@ -4303,7 +4313,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=="],
 
 
@@ -5099,11 +5109,15 @@
 
 
     "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
     "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
 
 
+    "solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
+
+    "solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
+
     "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
     "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
 
 
     "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
     "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
 
 
-    "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
+    "solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="],
 
 
     "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
     "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
 
 
@@ -5111,6 +5125,8 @@
 
 
     "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
     "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
 
 
+    "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
+
     "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
     "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
 
 
     "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
     "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
@@ -5591,8 +5607,6 @@
 
 
     "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
     "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
 
 
-    "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
-
     "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
     "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
 
 
     "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
     "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
@@ -5641,7 +5655,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=="],
 
 
@@ -5653,9 +5671,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=="],
 
 
@@ -5695,7 +5713,11 @@
 
 
     "@ai-sdk/vercel/@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/vercel/@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/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-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
+
+    "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
+
+    "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
 
 
     "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
     "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
 
 
@@ -5919,6 +5941,8 @@
 
 
     "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
     "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
 
 
+    "@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
+
     "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
     "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
 
 
     "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
     "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -6001,7 +6025,7 @@
 
 
     "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
     "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
 
 
-    "@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=="],
 
 
@@ -6027,6 +6051,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=="],
@@ -6143,8 +6169,14 @@
 
 
     "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/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
     "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
 
 
     "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
     "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -6367,7 +6399,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=="],
 
 
@@ -6443,6 +6475,8 @@
 
 
     "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
     "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
 
 
+    "solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
+
     "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
     "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
 
 
     "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
     "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
@@ -6579,6 +6613,8 @@
 
 
     "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
     "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
 
+    "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
     "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
 
 
     "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
     "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6871,6 +6907,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=="],
@@ -6941,6 +6981,16 @@
 
 
     "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=="],
+
     "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=="],
 
 
     "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
     "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -7091,6 +7141,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=="],
@@ -7339,6 +7393,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=="],
 
 
     "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
     "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7355,6 +7411,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=="],
@@ -7425,6 +7485,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 - 0
infra/lake.ts

@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
   configuration: {
   configuration: {
     enforceWorkgroupConfiguration: true,
     enforceWorkgroupConfiguration: true,
     publishCloudwatchMetricsEnabled: true,
     publishCloudwatchMetricsEnabled: true,
+    // Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
+    // so a regression cannot silently burn money. Stats sync full passes scan
+    // ~250 GB as of 2026-07.
+    bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
     resultConfiguration: {
     resultConfiguration: {
       outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
       outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
     },
     },

+ 19 - 0
infra/stage.ts

@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
 export const awsStage = $app.stage === "production" ? "production" : "dev"
 export const awsStage = $app.stage === "production" ? "production" : "dev"
 export const deployAws = $app.stage === awsStage
 export const deployAws = $app.stage === awsStage
 
 
+if ($app.stage === "production") {
+  new cloudflare.DnsRecord("TrustCenter", {
+    zoneId: zoneID,
+    name: "trust.opencode.ai",
+    type: "CNAME",
+    content: "3a69a5bb27875189.vercel-dns-016.com",
+    proxied: false,
+    ttl: 60,
+  })
+
+  new cloudflare.DnsRecord("TrustCenterVerification", {
+    zoneId: zoneID,
+    name: "opencode.ai",
+    type: "TXT",
+    content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
+    ttl: 60,
+  })
+}
+
 new cloudflare.RegionalHostname("RegionalHostname", {
 new cloudflare.RegionalHostname("RegionalHostname", {
   hostname: domain,
   hostname: domain,
   regionKey: "us",
   regionKey: "us",

+ 3 - 1
infra/stats.ts

@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
   cluster: lakeCluster,
   cluster: lakeCluster,
   architecture: "arm64",
   architecture: "arm64",
   cpu: "0.25 vCPU",
   cpu: "0.25 vCPU",
-  memory: "0.5 GB",
+  // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
+  // stats queries (~$5/pass) every ~5 minutes instead of hourly.
+  memory: "2 GB",
   image: {
   image: {
     context: ".",
     context: ".",
     dockerfile: "packages/stats/server/Dockerfile",
     dockerfile: "packages/stats/server/Dockerfile",

+ 66 - 33
nix/desktop.nix

@@ -8,6 +8,8 @@
   makeWrapper,
   makeWrapper,
   writableTmpDirAsHomeHook,
   writableTmpDirAsHomeHook,
   autoPatchelfHook,
   autoPatchelfHook,
+  copyDesktopItems,
+  makeDesktopItem,
   opencode,
   opencode,
 }:
 }:
 let
 let
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
     nodejs
     nodejs
     makeWrapper
     makeWrapper
     writableTmpDirAsHomeHook
     writableTmpDirAsHomeHook
-  ] ++ lib.optionals stdenv.hostPlatform.isLinux [
+  ]
+  ++ lib.optionals stdenv.hostPlatform.isLinux [
     autoPatchelfHook
     autoPatchelfHook
-  ] ++ lib.optionals stdenv.hostPlatform.isDarwin [
+    copyDesktopItems
+  ]
+  ++ lib.optionals stdenv.hostPlatform.isDarwin [
     # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
     # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
     darwin.autoSignDarwinBinariesHook
     darwin.autoSignDarwinBinariesHook
   ];
   ];
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
     (lib.getLib stdenv.cc.cc)
     (lib.getLib stdenv.cc.cc)
   ];
   ];
 
 
+  desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
+    name = "ai.opencode.desktop";
+    desktopName = "OpenCode";
+    exec = "opencode-desktop %U";
+    icon = "ai.opencode.desktop";
+    # Electron 41 derives X11 WM_CLASS from app.name.
+    startupWMClass = "OpenCode";
+    categories = [ "Development" ];
+  });
+
   env = opencode.env // {
   env = opencode.env // {
     ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
     ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
   };
   };
 
 
-  # https://github.com/electron/electron/issues/31121
-  # mac builds use a .app bundle which doesnt have this issue
-  postPatch = lib.optionalString stdenv.isLinux ''
-    BASE_PATH=packages/desktop
-    FILES=(src/main/windows.ts)
-    for file in "''${FILES[@]}"; do
-      substituteInPlace $BASE_PATH/$file \
-        --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
-    done
-  '';
+  postPatch =
+    # NOTE: Relax Bun version check to be a warning instead of an error
+    ''
+      substituteInPlace packages/script/src/index.ts \
+        --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
+                       'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
+    ''
+    # https://github.com/electron/electron/issues/31121
+    # mac builds use a .app bundle which doesnt have this issue
+    + lib.optionalString stdenv.isLinux ''
+      BASE_PATH=packages/desktop
+      FILES=(src/main/windows.ts)
+      for file in "''${FILES[@]}"; do
+        substituteInPlace $BASE_PATH/$file \
+          --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
+      done
+    '';
 
 
   preBuild = ''
   preBuild = ''
     cp -r "${electron.dist}" $HOME/.electron-dist
     cp -r "${electron.dist}" $HOME/.electron-dist
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
     runHook postBuild
     runHook postBuild
   '';
   '';
 
 
-  installPhase =
-    ''
-      runHook preInstall
-    ''
-    + lib.optionalString stdenv.hostPlatform.isDarwin ''
-      mkdir -p $out/Applications
-      mv dist/mac*/*.app $out/Applications
-      makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
-    ''
-    + lib.optionalString stdenv.hostPlatform.isLinux ''
-      mkdir -p $out/opt/opencode-desktop
-      cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
-      makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
-        --inherit-argv0 \
-        --set ELECTRON_FORCE_IS_PACKAGED 1 \
-        --add-flags $out/opt/opencode-desktop/resources/app.asar \
-        --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
-    ''
-    + ''
-      runHook postInstall
-    '';
+  installPhase = ''
+    runHook preInstall
+  ''
+  + lib.optionalString stdenv.hostPlatform.isDarwin ''
+    mkdir -p $out/Applications
+    mv dist/mac*/*.app $out/Applications
+    makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
+  ''
+  + lib.optionalString stdenv.hostPlatform.isLinux ''
+    mkdir -p $out/opt/opencode-desktop
+    cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
+    install -Dm644 resources/icons/32x32.png \
+      "$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/64x64.png \
+      "$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/128x128.png \
+      "$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/128x128@2x.png \
+      "$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/icon.png \
+      "$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
+      "$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
+    makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
+     --inherit-argv0 \
+     --set ELECTRON_FORCE_IS_PACKAGED 1 \
+     --add-flags $out/opt/opencode-desktop/resources/app.asar \
+     --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
+  ''
+  + ''
+    runHook postInstall
+  '';
 
 
   autoPatchelfIgnoreMissingDeps = [
   autoPatchelfIgnoreMissingDeps = [
     "libc.musl-x86_64.so.1"
     "libc.musl-x86_64.so.1"

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
 {
   "nodeModules": {
   "nodeModules": {
-    "x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
-    "aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
-    "aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
-    "x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
+    "x86_64-linux": "sha256-zAftZieWR0hWQQVkhEa826iueHaRnKxtyRodhNb+Apc=",
+    "aarch64-linux": "sha256-cTG+gB7CuiFgaYHSFbzXaXUI1C2kiugV3B9rUDRUwlg=",
+    "aarch64-darwin": "sha256-ap4Xv5ZliourkI4mcD0ktbzSI88jb4oVBv5mMK3WdUs=",
+    "x86_64-darwin": "sha256-NGRJMMnaMNrf3FkcYwr5PcfVm5thhlNLGIeMmAK0MeE="
   }
   }
 }
 }

+ 10 - 9
package.json

@@ -19,6 +19,7 @@
     "prepare": "husky",
     "prepare": "husky",
     "random": "echo 'Random script'",
     "random": "echo 'Random script'",
     "sso": "aws sso login --sso-session=opencode --no-browser",
     "sso": "aws sso login --sso-session=opencode --no-browser",
+    "translate:app": "bun run script/translate-app.ts",
     "test": "echo 'do not run tests from root' && exit 1"
     "test": "echo 'do not run tests from root' && exit 1"
   },
   },
   "workspaces": {
   "workspaces": {
@@ -39,13 +40,14 @@
       "@octokit/rest": "22.0.0",
       "@octokit/rest": "22.0.0",
       "@hono/standard-validator": "0.2.0",
       "@hono/standard-validator": "0.2.0",
       "@hono/zod-validator": "0.4.2",
       "@hono/zod-validator": "0.4.2",
-      "@opentui/core": "0.3.4",
-      "@opentui/keymap": "0.3.4",
-      "@opentui/solid": "0.3.4",
-      "@tanstack/solid-virtual": "3.13.28",
+      "@opentui/core": "0.4.5",
+      "@opentui/keymap": "0.4.5",
+      "@opentui/solid": "0.4.5",
+      "@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",
+      "@corvu/drawer": "0.2.4",
       "@types/luxon": "3.7.1",
       "@types/luxon": "3.7.1",
       "@types/node": "24.12.2",
       "@types/node": "24.12.2",
       "@types/semver": "7.7.1",
       "@types/semver": "7.7.1",
@@ -68,7 +70,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",
@@ -146,14 +148,13 @@
     "@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",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.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/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
+    "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.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",
     "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"
   }
   }
 }
 }

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

@@ -136,6 +136,9 @@ export async function setupTimeline(
         },
         },
       }),
       }),
     )
     )
+    if (settings.newLayoutDesigns === false) {
+      localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
+    }
   }, input.settings ?? {})
   }, input.settings ?? {})
   if (input.locale) {
   if (input.locale) {
     await page.addInitScript((locale) => {
     await page.addInitScript((locale) => {

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

@@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
   await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
   await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
 })
 })
 
 
+test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
+  await setupTimeline(page, {
+    messages: history(80),
+    viewport: { width: 1400, height: 700 },
+    reducedMotion: true,
+  })
+  const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+  const text = page.getByText("History 79.", { exact: false })
+  await expect(text).toBeVisible()
+  await scroller.evaluate((element) => {
+    element.dataset.selectionLength = "0"
+    document.addEventListener("selectionchange", () => {
+      element.dataset.selectionLength = String(
+        Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
+      )
+    })
+  })
+  const textBox = await text.boundingBox()
+  const scrollBox = await scroller.boundingBox()
+  expect(textBox).not.toBeNull()
+  expect(scrollBox).not.toBeNull()
+  if (!textBox || !scrollBox) return
+
+  await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
+  await page.mouse.down()
+  await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
+
+  await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
+  await expect
+    .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
+    .toBeGreaterThan(500)
+  await page.mouse.up()
+})
+
 test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
 test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
   const shellID = "prt_keyboard_01_shell"
   const shellID = "prt_keyboard_01_shell"
   const followingID = "prt_keyboard_02_following"
   const followingID = "prt_keyboard_02_following"

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

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

+ 151 - 0
packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts

@@ -0,0 +1,151 @@
+import type { Page } from "@playwright/test"
+import { expectSessionTitle } from "../../utils/waits"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+import { benchmark, expect, withBenchmarkPage } from "../benchmark"
+import { fixture } from "./session-timeline-stress.fixture"
+import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
+import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
+
+type ParentHydrationBenchmarkMode = "natural" | "candidate"
+
+const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
+if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
+const userID = "msg_parent_hydration_user"
+const user = {
+  ...fixture.messages[fixture.targetID][0]!,
+  info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
+  parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
+    ...part,
+    id: `prt_parent_hydration_user_${index}`,
+    messageID: userID,
+  })),
+}
+const assistantSeed = fixture.messages[fixture.targetID][3]!
+const assistants = Array.from({ length: 14 }, (_, index) => {
+  const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
+  return {
+    ...assistantSeed,
+    info: {
+      ...assistantSeed.info,
+      id: messageID,
+      parentID: userID,
+      time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
+    },
+    parts: assistantSeed.parts.map((part, partIndex) => ({
+      ...part,
+      id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
+      messageID,
+    })),
+  }
+})
+const messages = [user, ...assistants]
+const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
+const lastID = userID
+const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
+
+benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
+  benchmark.setTimeout(180_000)
+  const results = [] as Awaited<ReturnType<typeof trial>>[]
+  for (let run = 0; run < 5; run++) {
+    results.push(
+      await withBenchmarkPage(
+        browser,
+        `session-parent-hydration-${mode}-${run}`,
+        (page) => trial(page, mode),
+        testInfo,
+      ),
+    )
+  }
+  const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
+  report(
+    {
+      results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
+      summary: {
+        firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
+        blankSamples: results.map((result) => result.metrics.blankSamples),
+        requestCounts: {
+          list: results.map((result) => result.requestCounts.list),
+          parent: results.map((result) => result.requestCounts.parent),
+        },
+        historyGateCount: results.map((result) => result.historyGateCount),
+      },
+    },
+    { mode },
+  )
+})
+
+async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
+  const requests: { type: "list" | "parent"; before?: string }[] = []
+  const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
+  let historyGates = 0
+  await mockOpenCodeServer(page, {
+    sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
+    provider: fixture.provider,
+    directory: fixture.directory,
+    project: fixture.project,
+    messageDelay: 50,
+    onMessages: (request) => {
+      if (request.sessionID === fixture.targetID && request.phase === "start")
+        requests.push({ type: "list", before: request.before })
+    },
+    beforeMessagesResponse: (request) => {
+      if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
+      historyGates++
+      return history!.promise
+    },
+    onMessage: (request) => {
+      if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
+    },
+    message: (sessionID, messageID) => {
+      if (sessionID !== fixture.targetID || messageID !== userID) return
+      return user
+    },
+    pageMessages: (sessionID, limit, before) => {
+      const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
+      const end = before ? items.findIndex((message) => message.info.id === before) : items.length
+      const start = Math.max(0, end - limit)
+      return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
+    },
+  })
+  await page.route(`**/session/${fixture.targetID}`, (route) =>
+    route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
+  )
+  await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
+  await page.goto(stressSessionHref(fixture.sourceID))
+  await expectSessionTitle(page, fixture.expected.sourceTitle)
+  await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
+
+  const href = stressSessionHref(fixture.targetID)
+  await page.evaluate(
+    ({ href, title }) => {
+      const link = document.createElement("a")
+      link.id = "parent-hydration-target"
+      link.href = href
+      link.textContent = title
+      document.body.append(link)
+    },
+    { href, title: target.title },
+  )
+  const metrics = await measureSessionSwitch(page, {
+    destinationIDs: messages.map((message) => message.info.id),
+    sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
+    lastID,
+    requiredPartID: lastPartID,
+    requireBottomAnchor: false,
+    href,
+    switch: async () => {
+      await page.locator("#parent-hydration-target").click()
+      await expectSessionTitle(page, target.title)
+    },
+  }).finally(() => history?.resolve())
+  expect(metrics.firstCorrectObservedMs).not.toBeNull()
+  const requestCounts = {
+    list: requests.filter((request) => request.type === "list").length,
+    parent: requests.filter((request) => request.type === "parent").length,
+  }
+  if (mode === "candidate") {
+    expect(requestCounts.parent).toBe(1)
+    expect(historyGates).toBe(1)
+  }
+  return { metrics, requestCounts, historyGateCount: historyGates }
+}

+ 4 - 1
packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts

@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
   source: string[]
   source: string[]
   hasVisibleRows: boolean
   hasVisibleRows: boolean
   last: boolean
   last: boolean
+  requiredPartVisible?: boolean
+  bottomAnchorRequired?: boolean
   bottomErrorPx?: number
   bottomErrorPx?: number
   review?: {
   review?: {
     fileHost: boolean
     fileHost: boolean
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
     sample.destination.length > 0 &&
     sample.destination.length > 0 &&
     sample.source.length === 0 &&
     sample.source.length === 0 &&
     sample.last &&
     sample.last &&
-    Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
+    sample.requiredPartVisible !== false &&
+    (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
   )
   )
 }
 }
 
 

+ 48 - 8
packages/app/e2e/performance/timeline/session-tab-switch-probe.ts

@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
 
 
 async function installSessionSwitchProbe(
 async function installSessionSwitchProbe(
   page: Page,
   page: Page,
-  input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
+  input: {
+    destinationIDs: string[]
+    sourceIDs: string[]
+    lastID: string
+    requiredPartID?: string
+    requireBottomAnchor?: boolean
+    href: string
+  },
 ) {
 ) {
-  await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
+  await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
     const destination = new Set(destinationIDs)
     const destination = new Set(destinationIDs)
     const source = new Set(sourceIDs)
     const source = new Set(sourceIDs)
     const samples: SessionSwitchSample[] = []
     const samples: SessionSwitchSample[] = []
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
             const rect = element.getBoundingClientRect()
             const rect = element.getBoundingClientRect()
             return rect.bottom > view.top && rect.top < view.bottom
             return rect.bottom > view.top && rect.top < view.bottom
           })
           })
+          const requiredPartVisible = requiredPartID
+            ? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
+                if (element.dataset.timelinePartId !== requiredPartID) return false
+                const rect = element.getBoundingClientRect()
+                return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
+              })
+            : undefined
           const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
           const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
           samples.push({
           samples.push({
             observedAtMs,
             observedAtMs,
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
             source: visible.filter((id) => source.has(id)),
             source: visible.filter((id) => source.has(id)),
             hasVisibleRows,
             hasVisibleRows,
             last: visible.includes(lastID),
             last: visible.includes(lastID),
+            requiredPartVisible,
+            bottomAnchorRequired: requireBottomAnchor !== false,
             bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
             bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
             review,
             review,
           })
           })
         } else {
         } else {
-          samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
+          samples.push({
+            observedAtMs,
+            destination: [],
+            source: [],
+            hasVisibleRows: false,
+            last: false,
+            requiredPartVisible: requiredPartID ? false : undefined,
+            bottomAnchorRequired: requireBottomAnchor !== false,
+            review,
+          })
         }
         }
         requestAnimationFrame(sample)
         requestAnimationFrame(sample)
       }, 0)
       }, 0)
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
             sample.destination.length > 0 &&
             sample.destination.length > 0 &&
             sample.source.length === 0 &&
             sample.source.length === 0 &&
             sample.last &&
             sample.last &&
-            Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
+            sample.requiredPartVisible !== false &&
+            (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
         )
         )
       )
       )
     })
     })
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
 
 
 export async function measureSessionSwitch(
 export async function measureSessionSwitch(
   page: Page,
   page: Page,
-  input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
+  input: {
+    destinationIDs: string[]
+    sourceIDs: string[]
+    lastID: string
+    requiredPartID?: string
+    requireBottomAnchor?: boolean
+    href: string
+    switch: () => Promise<void>
+  },
 ) {
 ) {
   const { switch: run, ...probe } = input
   const { switch: run, ...probe } = input
   await installSessionSwitchProbe(page, probe)
   await installSessionSwitchProbe(page, probe)
-  await run()
-  await waitForStableSessionSwitch(page)
-  return collectSessionSwitchResult(page)
+  try {
+    await run()
+    await waitForStableSessionSwitch(page)
+    return await collectSessionSwitchResult(page)
+  } finally {
+    await page.evaluate(() => {
+      ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
+    })
+  }
 }
 }
 
 
 export async function waitForStableTimeline(page: Page, lastID: string) {
 export async function waitForStableTimeline(page: Page, lastID: string) {

+ 46 - 0
packages/app/e2e/performance/unit/mock-server.test.ts

@@ -0,0 +1,46 @@
+import { expect, test } from "bun:test"
+import type { Page, Route } from "@playwright/test"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+
+test("applies message latency after a list response gate is released", async () => {
+  const events: string[] = []
+  const gate = Promise.withResolvers<void>()
+  let handler: ((route: Route) => Promise<void>) | undefined
+  const page = {
+    route: (_url: string, callback: (route: Route) => Promise<void>) => {
+      handler = callback
+      return Promise.resolve()
+    },
+  } as unknown as Page
+  await mockOpenCodeServer(page, {
+    provider: {},
+    directory: "C:/OpenCode",
+    project: {},
+    sessions: [{ id: "session" }],
+    messageDelay: 25,
+    beforeMessagesResponse: () => {
+      events.push("before")
+      return gate.promise
+    },
+    onMessages: (request) => events.push(request.phase),
+    pageMessages: () => {
+      events.push("page")
+      return { items: [] }
+    },
+  })
+
+  const response = handler!({
+    request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
+    fulfill: () => {
+      events.push("fulfill")
+      return Promise.resolve()
+    },
+  } as unknown as Route)
+  expect(events).toEqual(["start", "before"])
+
+  const released = performance.now()
+  gate.resolve()
+  await response
+  expect(performance.now() - released).toBeGreaterThanOrEqual(20)
+  expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
+})

+ 32 - 0
packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts

@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
   expect(result.firstCorrectObservedMs).toBeNull()
   expect(result.firstCorrectObservedMs).toBeNull()
   expect(result.stableObservedMs).toBeNull()
   expect(result.stableObservedMs).toBeNull()
 })
 })
+
+test("requires an explicitly tracked part to be visible", () => {
+  const result = classifySessionSwitch([
+    {
+      observedAtMs: 16,
+      destination: ["destination"],
+      source: [],
+      hasVisibleRows: true,
+      last: true,
+      requiredPartVisible: false,
+      bottomErrorPx: 0,
+    },
+  ])
+
+  expect(result.firstCorrectObservedMs).toBeNull()
+})
+
+test("can measure content correctness without requiring a bottom anchor", () => {
+  const result = classifySessionSwitch([
+    {
+      observedAtMs: 16,
+      destination: ["destination"],
+      source: [],
+      hasVisibleRows: true,
+      last: true,
+      requiredPartVisible: true,
+      bottomAnchorRequired: false,
+    },
+  ])
+
+  expect(result.firstCorrectObservedMs).toBe(16)
+})

+ 55 - 0
packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts

@@ -0,0 +1,55 @@
+import { expect, test } from "bun:test"
+import type { Page } from "@playwright/test"
+import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
+
+function testPage(waitFailure?: Error) {
+  const stops: unknown[] = []
+  const page = {
+    evaluate: async (_callback: unknown, input?: unknown) => {
+      if (input) return
+      stops.push(undefined)
+    },
+    waitForFunction: async () => {
+      if (waitFailure) throw waitFailure
+    },
+  } as unknown as Page
+  return { page, stops }
+}
+
+function input(run: () => Promise<void>) {
+  return {
+    destinationIDs: ["destination"],
+    sourceIDs: ["source"],
+    lastID: "destination",
+    href: "/session/destination",
+    switch: run,
+  }
+}
+
+test("stops sampling when the session switch fails", async () => {
+  const failure = new Error("switch failed")
+  const context = testPage()
+
+  await expect(
+    measureSessionSwitch(
+      context.page,
+      input(async () => Promise.reject(failure)),
+    ),
+  ).rejects.toBe(failure)
+
+  expect(context.stops).toHaveLength(1)
+})
+
+test("stops sampling when the stable wait fails", async () => {
+  const failure = new Error("stable wait failed")
+  const context = testPage(failure)
+
+  await expect(
+    measureSessionSwitch(
+      context.page,
+      input(async () => {}),
+    ),
+  ).rejects.toBe(failure)
+
+  expect(context.stops).toHaveLength(1)
+})

+ 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 },
+  )
+}

+ 41 - 0
packages/app/e2e/regression/legacy-new-session.spec.ts

@@ -0,0 +1,41 @@
+import { expect, test } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { mockOpenCodeServer } from "../utils/mock-server"
+
+const draftID = "draft_legacy_new_session"
+const directory = "C:/OpenCode/LegacyNewSession"
+const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+
+test("redirects a draft to the legacy new-session route", async ({ page }) => {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: "proj_legacy_new_session",
+      worktree: directory,
+      vcs: "git",
+      name: "legacy-new-session",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: { all: [], connected: [], default: {} },
+    sessions: [],
+    pageMessages: () => ({ items: [] }),
+  })
+  await page.addInitScript(
+    ({ directory, draftID, server }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
+      localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify([{ type: "draft", draftID, server, directory }]),
+      )
+    },
+    { directory, draftID, server },
+  )
+
+  await page.goto(`/new-session?draftId=${draftID}`)
+
+  await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
+  await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
+  await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
+})

+ 3 - 6
packages/app/e2e/regression/prompt-thinking-level.spec.ts

@@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
   })
   })
 
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
-  const composer = page.locator('[data-component="session-composer"]')
+  const composer = page.locator('[data-component="prompt-input-v2"]')
   const input = composer.locator('[data-component="prompt-input"]')
   const input = composer.locator('[data-component="prompt-input"]')
-  const control = composer.locator('[data-component="prompt-variant-control"]')
+  const control = composer.getByRole("button", { name: "Choose model variant" })
   await expectAppVisible(composer)
   await expectAppVisible(composer)
 
 
   await idleComposer(page)
   await idleComposer(page)
-  await expect(control).toBeHidden()
-
-  await composer.hover()
   await expect(control).toBeVisible()
   await expect(control).toBeVisible()
 
 
-  await control.locator('[data-action="prompt-model-variant"]').click()
+  await control.click()
   const high = page.getByRole("menuitemradio", { name: "high" })
   const high = page.getByRole("menuitemradio", { name: "high" })
   await expect(high).toBeVisible()
   await expect(high).toBeVisible()
   await page.mouse.move(0, 0)
   await page.mouse.move(0, 0)

+ 270 - 0
packages/app/e2e/regression/remote-session-settings.spec.ts

@@ -0,0 +1,270 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page, type Route } from "@playwright/test"
+import { installSseTransport } from "../utils/sse-transport"
+
+const serverA = "http://127.0.0.1:4096"
+const serverB = "http://127.0.0.1:4097"
+const directoryA = "C:/server-a"
+const directoryB = "/home/server-b"
+const sessionA = session("ses_server_a", directoryA, "Server A session")
+const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
+const sessionB = session("ses_server_b", directoryB, "Server B session")
+
+test("session settings use the remote server context", async ({ page }) => {
+  const permissionRequests: string[] = []
+  await mockServers(page, permissionRequests)
+  await configureServers(page)
+
+  await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
+  await expect(page.getByText(sessionB.title).first()).toBeVisible()
+  await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
+
+  const dialog = page.locator(".settings-v2-dialog")
+  const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
+  const input = autoAccept.getByRole("switch")
+  await expect(autoAccept).toBeVisible()
+  await expect(input).toBeEnabled()
+  permissionRequests.length = 0
+  await autoAccept.locator('[data-slot="switch-control"]').click()
+  await expect(input).toBeChecked()
+  await expect
+    .poll(() =>
+      permissionRequests.some((request) => {
+        const url = new URL(request)
+        return url.origin === serverB && url.searchParams.get("directory") === directoryB
+      }),
+    )
+    .toBe(true)
+  expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
+
+  await dialog.getByRole("tab", { name: "Models" }).click()
+  await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
+  await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
+})
+
+test("auto-accept responds for an unfocused server session", async ({ page }) => {
+  const permissionRequests: string[] = []
+  const permissionResponses: PermissionResponse[] = []
+  const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
+    server: serverA,
+    retry: 20,
+  })
+  await mockServers(page, permissionRequests, permissionResponses)
+  await configureServers(page, [
+    { type: "session", server: serverA, sessionId: sessionA.id },
+    { type: "session", server: serverB, sessionId: sessionB.id },
+  ])
+
+  const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
+  await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
+  await expect(page.getByText(sessionA.title).first()).toBeVisible()
+  await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
+  const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
+  await autoAccept.locator('[data-slot="switch-control"]').click()
+  await expect(autoAccept.getByRole("switch")).toBeChecked()
+  await expect
+    .poll(() =>
+      permissionRequests.some((request) => {
+        const url = new URL(request)
+        return url.origin === serverA && url.searchParams.get("directory") === directoryA
+      }),
+    )
+    .toBe(true)
+  await page.keyboard.press("Escape")
+
+  await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
+  await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
+  await expect(page.getByText(sessionB.title).first()).toBeVisible()
+  await transport.waitForConnection()
+
+  await transport.send({
+    directory: directoryA,
+    payload: {
+      id: "event-permission-background-a",
+      type: "permission.asked",
+      properties: {
+        id: "permission-background-a",
+        sessionID: sessionA.id,
+        permission: "bash",
+        patterns: ["git status"],
+        metadata: {},
+        always: [],
+      },
+    },
+  })
+
+  await expect
+    .poll(() => permissionResponses)
+    .toEqual([
+      {
+        origin: serverA,
+        directory: directoryA,
+        sessionID: sessionA.id,
+        permissionID: "permission-background-a",
+        body: { response: "once" },
+      },
+    ])
+
+  await transport.send({
+    directory: directoryA,
+    payload: {
+      id: "event-permission-background-a-child",
+      type: "permission.asked",
+      properties: {
+        id: "permission-background-a-child",
+        sessionID: childSessionA.id,
+        permission: "bash",
+        patterns: ["git diff"],
+        metadata: {},
+        always: [],
+      },
+    },
+  })
+
+  await expect
+    .poll(() => permissionResponses)
+    .toEqual([
+      {
+        origin: serverA,
+        directory: directoryA,
+        sessionID: sessionA.id,
+        permissionID: "permission-background-a",
+        body: { response: "once" },
+      },
+      {
+        origin: serverA,
+        directory: directoryA,
+        sessionID: childSessionA.id,
+        permissionID: "permission-background-a-child",
+        body: { response: "once" },
+      },
+    ])
+})
+
+type PermissionResponse = {
+  origin: string
+  directory?: string
+  sessionID: string
+  permissionID: string
+  body: unknown
+}
+
+async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
+  await page.addInitScript(
+    ({ serverB, tabs }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
+      localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
+    },
+    { serverB, tabs },
+  )
+}
+
+async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
+  await page.route("**/*", async (route) => {
+    const url = new URL(route.request().url())
+    if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
+    const remote = url.origin === serverB
+    const directory = remote ? directoryB : directoryA
+    const sessions = remote ? [sessionB] : [sessionA, childSessionA]
+    const requestDirectory = url.searchParams.get("directory")
+    const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
+    if (route.request().method() === "POST" && response) {
+      permissionResponses.push({
+        origin: url.origin,
+        directory: requestDirectory ?? undefined,
+        sessionID: response[1]!,
+        permissionID: response[2]!,
+        body: route.request().postDataJSON(),
+      })
+      return json(route, true)
+    }
+    if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
+    if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
+    if (url.pathname === "/global/health") return json(route, { healthy: true })
+    if (url.pathname === "/session/status") return json(route, {})
+    if (url.pathname === "/session") return json(route, sessions)
+    const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
+    if (current) return json(route, current)
+    if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
+    if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
+    if (url.pathname === "/permission") {
+      permissionRequests.push(url.toString())
+      return json(route, [])
+    }
+    if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
+      return json(route, [])
+    if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
+    if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
+    if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
+    if (url.pathname === "/project" || url.pathname === "/project/current") {
+      const project = {
+        id: remote ? sessionB.projectID : "project-server-a",
+        worktree: directory,
+        vcs: "git",
+        time: { created: 1, updated: 1 },
+        sandboxes: [],
+      }
+      return json(route, url.pathname === "/project" ? [project] : project)
+    }
+    if (url.pathname === "/path")
+      return json(route, {
+        state: directory,
+        config: directory,
+        worktree: directory,
+        directory,
+        home: directory,
+      })
+    if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    return json(route, {})
+  })
+}
+
+function session(id: string, directory: string, title: string) {
+  return {
+    id,
+    slug: id,
+    projectID: `project-${id}`,
+    directory,
+    title,
+    version: "dev",
+    time: { created: 1, updated: 1 },
+  }
+}
+
+function provider(id: string) {
+  const name = id === "server-b" ? "Server B" : "Server A"
+  return {
+    all: [
+      {
+        id,
+        name: `${name} Provider`,
+        models: {
+          [id]: {
+            id,
+            name: `${name} Model`,
+            family: id,
+            release_date: "2026-01-01",
+            limit: { context: 200_000 },
+          },
+        },
+      },
+    ],
+    connected: [id],
+    default: { providerID: id, modelID: id },
+  }
+}
+
+function json(route: Route, body: unknown, status = 200) {
+  return route.fulfill({
+    status,
+    contentType: "application/json",
+    headers: { "access-control-allow-origin": "*" },
+    body: JSON.stringify(body),
+  })
+}
+
+function sse(route: Route) {
+  return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
+}

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

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

+ 152 - 0
packages/app/e2e/regression/review-state-persistence.spec.ts

@@ -0,0 +1,152 @@
+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/ReviewStatePersistence"
+const projectID = "proj_review_state_persistence"
+const sessionA = "ses_review_state_a"
+const sessionB = "ses_review_state_b"
+const titleA = "Alpha review state"
+const titleB = "Beta review state"
+const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+
+test.use({ viewport: { width: 1440, height: 900 } })
+
+test("restores review mode and selected file per session", async ({ page }) => {
+  await setup(page)
+  await page.goto(sessionHref(sessionA))
+  await expectSessionTitle(page, titleA)
+  await page.getByRole("button", { name: "Toggle review" }).click()
+
+  await selectMode(page, "Git changes", "Branch changes")
+  await selectFile(page, "beta.ts")
+
+  await switchSession(page, titleB)
+  await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
+  await selectFile(page, "gamma.ts")
+
+  await switchSession(page, titleA)
+  await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
+  await expectSelectedFile(page, "beta.ts")
+  await selectMode(page, "Branch changes", "Git changes")
+  await expectSelectedFile(page, "alpha.ts")
+  await selectMode(page, "Git changes", "Branch changes")
+  await expectSelectedFile(page, "beta.ts")
+
+  await page.reload()
+  await expectSessionTitle(page, titleA)
+  await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
+  await expectSelectedFile(page, "beta.ts")
+
+  await switchSession(page, titleB)
+  await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
+  await expectSelectedFile(page, "gamma.ts")
+})
+
+async function selectMode(page: Page, current: string, next: string) {
+  await page.getByRole("button", { name: current }).click()
+  await page.getByRole("option", { name: next }).click()
+}
+
+async function selectFile(page: Page, file: string) {
+  await page.getByRole("button", { name: file }).click()
+  await expectSelectedFile(page, file)
+}
+
+async function expectSelectedFile(page: Page, file: string) {
+  await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
+}
+
+async function switchSession(page: Page, title: string) {
+  await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
+  await expectSessionTitle(page, title)
+}
+
+async function setup(page: Page) {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "review-state-persistence",
+      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: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
+    pageMessages: () => ({ items: [] }),
+  })
+  await page.route(/\/vcs(?:\?.*)?$/, (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
+    }),
+  )
+  await page.route("**/vcs/diff**", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(
+        new URL(route.request().url()).searchParams.get("mode") === "branch"
+          ? [diff("src/alpha.ts"), diff("src/beta.ts")]
+          : [diff("src/alpha.ts"), diff("src/gamma.ts")],
+      ),
+    }),
+  )
+  await page.addInitScript(
+    ({ directory, server, sessions }) => {
+      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.window.browser.dat:tabs",
+        JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
+      )
+    },
+    { directory, server, sessions: [sessionA, sessionB] },
+  )
+}
+
+function session(id: string, title: string, created: number) {
+  return {
+    id,
+    slug: id,
+    projectID,
+    directory,
+    title,
+    version: "dev",
+    time: { created, updated: created },
+  }
+}
+
+function diff(file: string) {
+  return {
+    file,
+    additions: 1,
+    deletions: 1,
+    status: "modified",
+    patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
+  }
+}
+
+function sessionHref(sessionID: string) {
+  return `/server/${base64Encode(server)}/session/${sessionID}`
+}

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

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

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

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

+ 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: {

+ 242 - 0
packages/app/e2e/regression/session-timeline-history-root.spec.ts

@@ -0,0 +1,242 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import {
+  assistantMessage,
+  directory,
+  messageUpdated,
+  project,
+  session,
+  sessionID,
+  status,
+  textPart,
+  title,
+  userID,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { installSseTransport } from "../utils/sse-transport"
+import { expectSessionTitle } from "../utils/waits"
+
+const initialPageSize = 20
+const historyPageSize = 200
+const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
+  assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
+    id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
+    parentID: userID,
+    created: 1700000001000 + index * 1_000,
+    completed: index < initialPageSize,
+  }),
+)
+const messages = [userMessage(), ...assistants]
+const lastAssistant = assistants.at(-1)!
+const lastPartID = assistants.at(-1)!.parts[0]!.id
+const userPartID = `prt_${userID}_text`
+const completed = {
+  ...lastAssistant.info,
+  time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
+}
+const scenarios = [
+  { name: "completion", info: completed, idleFirst: false, interrupted: false },
+  {
+    name: "interruption",
+    info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
+    idleFirst: true,
+    interrupted: true,
+  },
+] as const
+
+test.use({ viewport: { width: 646, height: 1385 } })
+
+for (const scenario of scenarios) {
+  test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
+    const requests: { before?: string; phase: "start" | "end" }[] = []
+    const pages: { before?: string; limit: number }[] = []
+    const roots: { sessionID: string; messageID: string }[] = []
+    const sequence: string[] = []
+    const history = Promise.withResolvers<void>()
+    const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
+      server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
+      retry: 20,
+    })
+    await mockOpenCodeServer(page, {
+      directory,
+      project: project(),
+      provider: {
+        all: [
+          {
+            id: "opencode",
+            name: "OpenCode",
+            models: {
+              "claude-opus-4-6": {
+                id: "claude-opus-4-6",
+                name: "Claude Opus 4.6",
+                limit: { context: 200_000 },
+              },
+            },
+          },
+        ],
+        connected: ["opencode"],
+        default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+      },
+      sessions: [session()],
+      sessionStatus: { [sessionID]: { type: "busy" } },
+      beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
+      onMessages: (request) => {
+        requests.push(request)
+        sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
+      },
+      onMessage: (request) => {
+        roots.push(request)
+        sequence.push(`message:${request.messageID}`)
+      },
+      message: (requestedSessionID, messageID) => {
+        if (requestedSessionID !== sessionID) return
+        return messages.find((item) => item.info.id === messageID)
+      },
+      pageMessages: (_, limit, before) => {
+        pages.push({ before, limit })
+        const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
+        const start = Math.max(0, end - limit)
+        return {
+          items: messages.slice(start, end),
+          cursor: start > 0 ? messages[start]!.info.id : undefined,
+        }
+      },
+    })
+    await page.addInitScript(() => {
+      const visibleParts = () => {
+        const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
+        const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
+        const view = viewport?.getBoundingClientRect()
+        if (!viewport || !view) return []
+        return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
+          .filter((part) => {
+            const rect = part.getBoundingClientRect()
+            return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
+          })
+          .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
+      }
+      const state = {
+        armed: false,
+        hidden: false,
+        visibleParts: [] as string[],
+        samples: 0,
+        stop: false,
+        arm() {
+          state.visibleParts = visibleParts()
+          state.armed = true
+        },
+      }
+      ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
+      const sample = () => {
+        if (state.armed) {
+          const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
+          const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
+          const view = viewport?.getBoundingClientRect()
+          const visible = (partID: string) => {
+            const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
+            const rect = part?.getBoundingClientRect()
+            return (
+              !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
+            )
+          }
+          if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
+            state.hidden = true
+          state.samples++
+        }
+        if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
+      }
+      requestAnimationFrame(() => setTimeout(sample, 0))
+    })
+
+    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+    await transport.waitForConnection()
+    await expectSessionTitle(page, title)
+    await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
+    await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
+    await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
+    expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
+    expect(sequence.slice(0, 4)).toEqual([
+      "messages:start:latest",
+      "messages:end:latest",
+      `message:${userID}`,
+      `messages:start:${messages.at(-initialPageSize)!.info.id}`,
+    ])
+    await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
+    await page.evaluate(() => {
+      ;(
+        window as Window & {
+          __historyRootProbe?: { arm(): void }
+        }
+      ).__historyRootProbe!.arm()
+    })
+    await waitForProbeSamples(page, 0)
+    expect(await visibleContentHidden(page)).toBe(false)
+    const beforeHistory = await probeSamples(page)
+    history.resolve()
+    await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
+    await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
+    await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
+    await waitForProbeSamples(page, beforeHistory)
+    expect(pages).toEqual([
+      { before: undefined, limit: initialPageSize },
+      { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
+    ])
+    expect(roots).toEqual([{ sessionID, messageID: userID }])
+
+    const message = messageUpdated(scenario.info)
+    const idle = status("idle")
+    for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
+      const beforeEvent = await probeSamples(page)
+      await transport.send(event)
+      if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
+      if (event === message && scenario.interrupted)
+        await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
+      await waitForProbeSamples(page, beforeEvent)
+      const current = await timelineState(page)
+      expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
+      expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
+    }
+
+    expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
+    expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
+    await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
+    await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
+    if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
+    expect(
+      await page.evaluate(() => {
+        const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
+          .__historyRootProbe!
+        state.stop = true
+        return state.hidden
+      }),
+    ).toBe(false)
+  })
+}
+
+function timelineState(page: Page) {
+  return page.evaluate(() => ({
+    virtual: !!document.querySelector("[data-timeline-virtual-content]"),
+    rows: document.querySelectorAll("[data-timeline-key]").length,
+  }))
+}
+
+function probeSamples(page: Page) {
+  return page.evaluate(
+    () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
+  )
+}
+
+async function waitForProbeSamples(page: Page, after: number) {
+  await page.waitForFunction(
+    (after) =>
+      (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
+    after,
+  )
+}
+
+function visibleContentHidden(page: Page) {
+  return page.evaluate(
+    () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
+  )
+}

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

@@ -152,7 +152,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))
 
 

+ 228 - 0
packages/app/e2e/regression/session-timeline-shell-outline.spec.ts

@@ -0,0 +1,228 @@
+import { expect, test, type Locator, type Page } from "@playwright/test"
+import {
+  assistantMessage,
+  setupTimeline,
+  shell,
+  textPart,
+  toolPart,
+  userMessage,
+} from "../performance/timeline-stability/fixture"
+
+for (const deviceScaleFactor of [1.25, 1.5]) {
+  test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
+    const shellID = "prt_shell_outline"
+    const timeline = await setupTimeline(page, {
+      messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
+      settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
+      reducedMotion: true,
+      deviceScaleFactor,
+    })
+    const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
+    const output = part.locator('[data-component="bash-output"]')
+    const row = page.locator("[data-timeline-key]", { has: part })
+    await expect(output).toBeVisible()
+    await timeline.settle()
+
+    const geometry = await row.evaluate((element) => {
+      const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
+      if (!output) throw new Error("Shell output is unavailable")
+      const rowRect = element.getBoundingClientRect()
+      const outputRect = output.getBoundingClientRect()
+      // Match a rounded-down measurement at a fractional device-pixel phase.
+      element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
+      element.style.transform = "translateY(0.25px)"
+      output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
+      output.style.setProperty("background", "rgb(0, 0, 0)", "important")
+      const style = getComputedStyle(output)
+      return {
+        outputWidth: outputRect.width,
+        outputHeight: outputRect.height,
+        borderColor: style.borderTopColor,
+        boxShadow: style.boxShadow,
+        clipMargin: getComputedStyle(element).overflowClipMargin,
+      }
+    })
+    await timeline.settle()
+
+    const clipped = await row.evaluate((element) => {
+      const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
+      return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
+    })
+    expect(clipped).toBeCloseTo(0.49, 1)
+
+    expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
+    const edges = await captureCardEdges(page, output)
+
+    expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
+    expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
+    expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
+    expect(geometry.boxShadow).toBe("none")
+    expect(geometry.clipMargin).toBe("0.5px")
+    expect(edges.magenta.top).toBeGreaterThan(0.75)
+    expect(edges.magenta.bottom).toBeGreaterThan(0.75)
+    expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
+  })
+}
+
+test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
+  const patchID = "prt_patch_outline"
+  const file = {
+    filePath: "src/outline.ts",
+    relativePath: "src/outline.ts",
+    type: "update",
+    additions: 1,
+    deletions: 1,
+    before: "const outline = false\n",
+    after: "const outline = true\n",
+  }
+  const timeline = await setupTimeline(page, {
+    messages: [
+      userMessage(),
+      assistantMessage([
+        toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
+      ]),
+    ],
+    settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
+    reducedMotion: true,
+  })
+  const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
+  const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
+  const row = page.locator("[data-timeline-key]", { has: part })
+  await expect(card).toBeVisible()
+  await timeline.settle()
+
+  const geometry = await row.evaluate((element) => {
+    const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
+    if (!card) throw new Error("Patch card is unavailable")
+    const rowRect = element.getBoundingClientRect()
+    const cardRect = card.getBoundingClientRect()
+    element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
+    const clipMargin = getComputedStyle(element).overflowClipMargin
+    const bottom = element.getBoundingClientRect().bottom
+    return {
+      overflow: card.getBoundingClientRect().bottom - bottom,
+      paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
+      clipMargin,
+      cardWidth: cardRect.width,
+      cardHeight: cardRect.height,
+    }
+  })
+  await timeline.settle()
+
+  expect(geometry.overflow).toBeCloseTo(0.49, 1)
+  expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
+  const edges = await captureCardEdges(page, card)
+  expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
+  expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
+  expect(edges.luminance.top).toBeLessThan(245)
+  expect(edges.luminance.bottom).toBeLessThan(245)
+  expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
+  expect(geometry.clipMargin).toBe("0.5px")
+})
+
+test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
+  const secondUserID = "msg_outline_second_user"
+  await setupTimeline(page, {
+    messages: [
+      userMessage(undefined, {
+        summary: {
+          diffs: [
+            {
+              file: "src/summary.ts",
+              additions: 1,
+              deletions: 1,
+              patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
+            },
+          ],
+        },
+      }),
+      assistantMessage([textPart("prt_outline_text", "Assistant text")]),
+      userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
+      assistantMessage([], {
+        id: "msg_outline_second_assistant",
+        parentID: secondUserID,
+        created: 1700000011000,
+      }),
+    ],
+  })
+  await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
+
+  const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
+    elements.map((element) => ({
+      tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
+      clipMargin: getComputedStyle(element).overflowClipMargin,
+    })),
+  )
+  expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
+  expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
+})
+
+async function captureCardEdges(page: Page, card: Locator) {
+  const box = await card.boundingBox()
+  if (!box) throw new Error("Tool card bounds are unavailable")
+  const viewport = page.viewportSize()
+  if (!viewport) throw new Error("Viewport bounds are unavailable")
+  const screenshot = await page.screenshot()
+  return page.evaluate(
+    async ({ source, box, viewport }) => {
+      const image = new Image()
+      image.src = source
+      await image.decode()
+      const canvas = document.createElement("canvas")
+      canvas.width = image.naturalWidth
+      canvas.height = image.naturalHeight
+      const context = canvas.getContext("2d")
+      if (!context) throw new Error("2D canvas is unavailable")
+      context.drawImage(image, 0, 0)
+      const scale = {
+        x: image.naturalWidth / viewport.width,
+        y: image.naturalHeight / viewport.height,
+      }
+      const rows = (candidates: number[]) => {
+        const left = Math.floor((box.x + 8) * scale.x)
+        const width = Math.floor((box.width - 16) * scale.x)
+        return candidates.map((row) => {
+          const pixels = context.getImageData(left, row, width, 1).data
+          const indexes = Array.from({ length: width }, (_, index) => index * 4)
+          return {
+            luminance:
+              indexes
+                .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
+                .reduce((sum, value) => sum + value, 0) / width,
+            magenta:
+              indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
+                .length / width,
+          }
+        })
+      }
+      const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
+      const columns = new Uint32Array(image.naturalWidth)
+      for (let index = 0; index < pixels.length; index += 4) {
+        if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
+        columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
+      }
+      const top = box.y * scale.y
+      const bottom = (box.y + box.height) * scale.y
+      const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
+      const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
+      return {
+        box,
+        luminance: {
+          top: Math.min(...topRows.map((row) => row.luminance)),
+          bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
+        },
+        magenta: {
+          top: Math.max(...topRows.map((row) => row.magenta)),
+          bottom: Math.max(...bottomRows.map((row) => row.magenta)),
+          vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
+        },
+      }
+    },
+    {
+      source: `data:image/png;base64,${screenshot.toString("base64")}`,
+      viewport,
+      box,
+    },
+  )
+}

+ 122 - 2
packages/app/e2e/regression/terminal-composer-focus.spec.ts

@@ -1,5 +1,5 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
-import { expect, test } from "@playwright/test"
+import { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { expectSessionTitle } from "../utils/waits"
 import { expectSessionTitle } from "../utils/waits"
 
 
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
 const projectID = "proj_terminal_composer_focus"
 const projectID = "proj_terminal_composer_focus"
 const sessionID = "ses_terminal_composer_focus"
 const sessionID = "ses_terminal_composer_focus"
 const ptyID = "pty_terminal_composer_focus"
 const ptyID = "pty_terminal_composer_focus"
+const newPtyID = "pty_terminal_composer_focus_new"
 
 
 test.use({ viewport: { width: 1440, height: 900 } })
 test.use({ viewport: { width: 1440, height: 900 } })
 
 
-test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
+test.beforeEach(async ({ page }) => {
   await mockOpenCodeServer(page, {
   await mockOpenCodeServer(page, {
     directory,
     directory,
     project: {
     project: {
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
   await page.addInitScript(() => {
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
   })
   })
+})
 
 
+test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, "Terminal composer focus")
   await expectSessionTitle(page, "Terminal composer focus")
 
 
@@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
   await expect(composer).toBeFocused()
   await expect(composer).toBeFocused()
   await expect(composer).toHaveText("a")
   await expect(composer).toHaveText("a")
 })
 })
+
+test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
+  const ghostty = Promise.withResolvers<void>()
+  const release = Promise.withResolvers<void>()
+  const created = { count: 0 }
+  await page.route("**/pty", (route) => {
+    created.count += 1
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+    })
+  })
+  await page.route(/ghostty-web/, async (route) => {
+    ghostty.resolve()
+    await release.promise
+    await route.continue()
+  })
+  await seedCachedTerminal(page)
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
+  await expectSessionTitle(page, "Terminal composer focus")
+
+  const composer = page.locator('[data-component="prompt-input"]')
+  const terminal = page.locator('[data-component="terminal"]')
+  await expect(terminal).toBeVisible()
+  expect(created.count).toBe(0)
+  await ghostty.promise
+  await composer.click()
+  await expect(composer).toBeFocused()
+
+  release.resolve()
+  await expect(terminal.locator("textarea")).toHaveCount(1)
+  await page.waitForTimeout(300)
+  await expect(composer).toBeFocused()
+})
+
+test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
+  const ghostty = Promise.withResolvers<void>()
+  const release = Promise.withResolvers<void>()
+  await page.route(/ghostty-web/, async (route) => {
+    ghostty.resolve()
+    await release.promise
+    await route.continue()
+  })
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await expectSessionTitle(page, "Terminal composer focus")
+
+  const composer = page.locator('[data-component="prompt-input"]')
+  const terminal = page.locator('[data-component="terminal"]')
+  await page.keyboard.press("Control+Backquote")
+  await expect(terminal).toBeVisible()
+  await ghostty.promise
+  await composer.click()
+  await expect(composer).toBeFocused()
+
+  release.resolve()
+  await expect(terminal.locator("textarea")).toHaveCount(1)
+  await page.waitForTimeout(50)
+  await expect(composer).toBeFocused()
+})
+
+test("focuses a terminal created from the new-terminal button", async ({ page }) => {
+  const created = { count: 0 }
+  await page.route("**/pty", (route) => {
+    created.count += 1
+    const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(next),
+    })
+  })
+  await page.route(`**/pty/${newPtyID}`, (route) =>
+    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  )
+  await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      headers: { "access-control-allow-origin": "*" },
+      body: JSON.stringify({ ticket: "e2e-ticket" }),
+    }),
+  )
+  await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await expectSessionTitle(page, "Terminal composer focus")
+
+  const composer = page.locator('[data-component="prompt-input"]')
+  const terminal = page.locator('[data-component="terminal"]')
+  await page.keyboard.press("Control+Backquote")
+  await expect(terminal.locator("textarea")).toHaveCount(1)
+  await composer.click()
+  await expect(composer).toBeFocused()
+
+  await page.getByRole("button", { name: "New terminal" }).click()
+  await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
+  await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
+})
+
+function seedCachedTerminal(page: Page) {
+  return page.addInitScript(
+    ({ terminalKey, ptyID }) => {
+      localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
+      localStorage.setItem(
+        terminalKey,
+        JSON.stringify({
+          active: ptyID,
+          all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
+        }),
+      )
+    },
+    { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
+  )
+}

+ 3 - 2
packages/app/e2e/regression/terminal-hidden.spec.ts

@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
 const sessionID = "ses_hidden_terminal_regression"
 const sessionID = "ses_hidden_terminal_regression"
 const title = "Hidden terminal regression"
 const title = "Hidden terminal regression"
 
 
-test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
+test("unmounts the terminal panel while it is hidden", async ({ page }) => {
   await page.setViewportSize({ width: 1400, height: 900 })
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
   await mockOpenCodeServer(page, {
     directory,
     directory,
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
   await expect(page.locator('[data-component="terminal"]')).toBeVisible()
   await expect(page.locator('[data-component="terminal"]')).toBeVisible()
 
 
   await page.keyboard.press("Control+Backquote")
   await page.keyboard.press("Control+Backquote")
-  await expect(panel).toHaveAttribute("aria-hidden", "true")
+  await expect(panel).toHaveCount(0)
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
 
 
   await page.setViewportSize({ width: 1200, height: 700 })
   await page.setViewportSize({ width: 1200, height: 700 })
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
 
 
   await page.keyboard.press("Control+Backquote")
   await page.keyboard.press("Control+Backquote")
+  await expect(panel).toBeVisible()
   await expect(page.locator('[data-component="terminal"]')).toBeVisible()
   await expect(page.locator('[data-component="terminal"]')).toBeVisible()
 })
 })
 
 

+ 34 - 0
packages/app/e2e/reproduction/timeline-suspense/index.html

@@ -0,0 +1,34 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Timeline Suspense Reproduction</title>
+    <style>
+      * {
+        box-sizing: border-box;
+      }
+
+      html,
+      body,
+      #root {
+        margin: 0;
+        min-height: 100%;
+      }
+
+      body {
+        background: #171717;
+        color: #f5f5f5;
+        font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+      }
+
+      #root {
+        padding: 24px;
+      }
+    </style>
+  </head>
+  <body>
+    <main id="root"></main>
+    <script type="module" src="/main.tsx"></script>
+  </body>
+</html>

+ 315 - 0
packages/app/e2e/reproduction/timeline-suspense/main.tsx

@@ -0,0 +1,315 @@
+import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
+import { render } from "solid-js/web"
+import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
+import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
+
+const rowCount = 2_000
+const rowHeight = 40
+const parameters = new URLSearchParams(location.search)
+const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
+const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
+
+type MutationEvent = {
+  kind: "removed" | "added"
+  callbackTime: number
+  callbackFrame: number
+  routeConnectedInCallback: boolean
+  nativeOffsetInCallback: number
+}
+
+type Snapshot = {
+  mode: {
+    resource: "baseline" | "guard"
+    reconnect: "baseline" | "candidate"
+  }
+  operation: {
+    sequence: number
+    phase: string
+    time: number
+    frame: number
+  }
+  resourceState: string
+  routeConnected: boolean
+  viewportConnected: boolean
+  viewportOwnedByRoute: boolean
+  sameRoute: boolean
+  sameViewport: boolean
+  sameSurface: boolean
+  sameMountedRows: boolean
+  nativeOffset: number
+  coreOffset: number
+  rangeStart: number
+  rangeEnd: number
+  indexes: number[]
+  domIndexes: number[]
+  logicalSurfaceHeight: number
+  renderedSurfaceHeight: number
+  viewportClientHeight: number
+  viewportScrollHeight: number
+  visibleRows: number
+  minimumRowTop: number
+  domScrollEvents: number
+  lastScrollTrusted: boolean
+  coreOffsetCallbackCalls: number
+  offsetCallbackSources: "observer"[]
+  rectObserverCallbacks: number
+  ignoredDetachedZeroRects: number
+  syntheticScrollDispatches: number
+  mutationEvents: MutationEvent[]
+}
+
+declare global {
+  interface Window {
+    timelineSuspense: {
+      prepare: () => Promise<Snapshot>
+      trigger: () => void
+      resolve: () => void
+      frames: (count?: number) => Promise<void>
+      snapshot: () => Snapshot
+    }
+  }
+}
+
+function App() {
+  const [refresh, setRefresh] = createSignal(false)
+  let resolveResource: (() => void) | undefined
+  const [resource] = createResource(
+    refresh,
+    (version) =>
+      new Promise<string>((resolve) => {
+        resolveResource = () => resolve(`settled-${version}`)
+      }),
+    { initialValue: "settled" },
+  )
+
+  function Route() {
+    let route: HTMLElement | undefined
+    let viewport: HTMLDivElement | undefined
+    let surface: HTMLDivElement | undefined
+    let initialRoute: HTMLElement | undefined
+    let initialViewport: HTMLDivElement | undefined
+    let initialSurface: HTMLDivElement | undefined
+    let initialRows: HTMLElement[] = []
+    let phase = "mounting"
+    let browserFrame = 0
+    let snapshotSequence = 0
+    let domScrollEvents = 0
+    let lastScrollTrusted = false
+    let coreOffsetCallbackCalls = 0
+    let rectObserverCallbacks = 0
+    let ignoredDetachedZeroRects = 0
+    const offsetCallbackSources: "observer"[] = []
+    const mutationEvents: MutationEvent[] = []
+    const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+      count: rowCount,
+      getScrollElement: () => viewport ?? null,
+      estimateSize: () => rowHeight,
+      initialRect: { width: 900, height: 600 },
+      overscan: 2,
+      observeElementRect: (instance, callback) =>
+        observeElementRect(instance, (rect) => {
+          rectObserverCallbacks++
+          // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
+          if (!instance.scrollElement?.isConnected && rect.height === 0) {
+            ignoredDetachedZeroRects++
+            return
+          }
+          callback(rect)
+        }),
+      observeElementOffset: (instance, callback) => {
+        const deliver = (offset: number, isScrolling: boolean) => {
+          coreOffsetCallbackCalls++
+          offsetCallbackSources.push("observer")
+          callback(offset, isScrolling)
+        }
+        if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
+        return observeElementOffset(instance, deliver)
+      },
+    })
+
+    const frames = async (count = 2) => {
+      for (let index = 0; index < count; index++) {
+        await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+      }
+    }
+    const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
+    const snapshot = (): Snapshot => {
+      const rows = mountedRows()
+      const view = viewport?.getBoundingClientRect()
+      const visibleRows =
+        viewport?.isConnected && view
+          ? rows.filter((row) => {
+              const rect = row.getBoundingClientRect()
+              return rect.bottom > view.top && rect.top < view.bottom
+            }).length
+          : 0
+      return {
+        mode: { resource: resourceMode, reconnect: reconnectMode },
+        operation: {
+          sequence: ++snapshotSequence,
+          phase,
+          time: performance.now(),
+          frame: browserFrame,
+        },
+        resourceState: resource.state,
+        routeConnected: route?.isConnected ?? false,
+        viewportConnected: viewport?.isConnected ?? false,
+        viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
+        sameRoute: route === initialRoute,
+        sameViewport: viewport === initialViewport,
+        sameSurface: surface === initialSurface,
+        sameMountedRows:
+          initialRows.length > 0 &&
+          initialRows.length === rows.length &&
+          initialRows.every((row, index) => row === rows[index]),
+        nativeOffset: viewport?.scrollTop ?? -1,
+        coreOffset: virtualizer.scrollOffset ?? -1,
+        rangeStart: virtualizer.range?.startIndex ?? -1,
+        rangeEnd: virtualizer.range?.endIndex ?? -1,
+        indexes: virtualizer.getVirtualItems().map((item) => item.index),
+        domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
+        logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
+        renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
+        viewportClientHeight: viewport?.clientHeight ?? -1,
+        viewportScrollHeight: viewport?.scrollHeight ?? -1,
+        visibleRows,
+        minimumRowTop:
+          rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
+        domScrollEvents,
+        lastScrollTrusted,
+        coreOffsetCallbackCalls,
+        offsetCallbackSources: [...offsetCallbackSources],
+        rectObserverCallbacks,
+        ignoredDetachedZeroRects,
+        syntheticScrollDispatches: 0,
+        mutationEvents: mutationEvents.map((event) => ({ ...event })),
+      }
+    }
+
+    onMount(() => {
+      if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
+      const routeRoot = route.parentElement
+      if (!routeRoot) throw new Error("Timeline route root did not mount")
+      initialRoute = route
+      initialViewport = viewport
+      initialSurface = surface
+      viewport.addEventListener("scroll", (event) => {
+        domScrollEvents++
+        lastScrollTrusted = event.isTrusted
+      })
+      const countFrames = () => {
+        browserFrame++
+        requestAnimationFrame(countFrames)
+      }
+      requestAnimationFrame(countFrames)
+      new MutationObserver((records) => {
+        const callbackTime = performance.now()
+        records.forEach((record) => {
+          ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
+            if (node !== route) return
+            phase = "detached"
+            mutationEvents.push({
+              kind: "removed",
+              callbackTime,
+              callbackFrame: browserFrame,
+              routeConnectedInCallback: route.isConnected,
+              nativeOffsetInCallback: viewport.scrollTop,
+            })
+          })
+          ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
+            if (node !== route) return
+            phase = "reinserted"
+            mutationEvents.push({
+              kind: "added",
+              callbackTime,
+              callbackFrame: browserFrame,
+              routeConnectedInCallback: route.isConnected,
+              nativeOffsetInCallback: viewport.scrollTop,
+            })
+          })
+        })
+      }).observe(routeRoot, { childList: true })
+      window.timelineSuspense = {
+        prepare: async () => {
+          phase = "preparing"
+          await frames(2)
+          viewport.scrollTop = viewport.scrollHeight
+          await frames(3)
+          await new Promise((resolve) => setTimeout(resolve, 200))
+          await frames(2)
+          initialRows = mountedRows()
+          phase = "prepared"
+          return snapshot()
+        },
+        trigger: () => {
+          phase = "triggering"
+          setRefresh(true)
+        },
+        resolve: () => {
+          if (!resolveResource) throw new Error("Resource is not pending")
+          phase = "resolving"
+          resolveResource()
+        },
+        frames,
+        snapshot,
+      }
+    })
+
+    return (
+      <section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
+        <span aria-hidden="true" style={{ display: "none" }}>
+          {resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
+        </span>
+        <div
+          ref={viewport}
+          data-viewport
+          style={{
+            height: "600px",
+            overflow: "auto",
+            "overflow-anchor": "none",
+            position: "relative",
+            background: "#202020",
+            outline: "1px solid #3f3f46",
+          }}
+        >
+          <div
+            ref={surface}
+            data-surface
+            style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
+          >
+            <For each={virtualizer.getVirtualItems()}>
+              {(item) => (
+                <div
+                  data-row-index={item.index}
+                  style={{
+                    position: "absolute",
+                    top: "0",
+                    left: "0",
+                    width: "100%",
+                    height: `${item.size}px`,
+                    transform: `translateY(${item.start}px)`,
+                    padding: "10px 14px",
+                    border: "0 solid #333",
+                    "border-bottom-width": "1px",
+                  }}
+                >
+                  logical row {item.index}
+                </div>
+              )}
+            </For>
+          </div>
+        </div>
+      </section>
+    )
+  }
+
+  return (
+    <main>
+      <Suspense>
+        <Route />
+      </Suspense>
+    </main>
+  )
+}
+
+render(() => <App />, document.getElementById("root")!)

+ 34 - 0
packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts

@@ -0,0 +1,34 @@
+import { defineConfig, devices } from "@playwright/test"
+
+const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
+
+export default defineConfig({
+  testDir: ".",
+  testMatch: "timeline-suspense.repro.ts",
+  outputDir: "../../test-results/timeline-suspense",
+  fullyParallel: false,
+  workers: 1,
+  retries: 0,
+  reporter: "line",
+  timeout: 30_000,
+  expect: {
+    timeout: 10_000,
+  },
+  webServer: {
+    command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
+    cwd: import.meta.dirname,
+    url: `http://127.0.0.1:${port}`,
+    reuseExistingServer: false,
+  },
+  use: {
+    baseURL: `http://127.0.0.1:${port}`,
+    trace: "retain-on-failure",
+    screenshot: "only-on-failure",
+  },
+  projects: [
+    {
+      name: "chromium",
+      use: { ...devices["Desktop Chrome"] },
+    },
+  ],
+})

+ 179 - 0
packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts

@@ -0,0 +1,179 @@
+import { expect, test, type Page } from "@playwright/test"
+
+test.beforeEach(async ({ page }) => {
+  page.on("pageerror", (error) => console.error(error))
+  await page.goto("/")
+  await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
+})
+
+test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
+  await page.goto("/?reconnect=candidate")
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
+  const before = await prepare(page)
+  await triggerBaselineSuspension(page)
+  const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
+  expect(pending.nativeOffset).toBe(0)
+  expect(pending.coreOffset).toBe(before.coreOffset)
+  expect(pending.indexes).toEqual(before.indexes)
+  expect(pending.sameRoute).toBe(true)
+  expect(pending.sameViewport).toBe(true)
+  expect(pending.sameSurface).toBe(true)
+  expect(pending.sameMountedRows).toBe(true)
+
+  await resolveSuspension(page)
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
+  await page.waitForTimeout(250)
+  await page.evaluate(() => window.timelineSuspense.frames(2))
+  const after = await page.evaluate(() => window.timelineSuspense.snapshot())
+  expect(after.sameRoute).toBe(true)
+  expect(after.sameViewport).toBe(true)
+  expect(after.sameSurface).toBe(true)
+  expect(after.nativeOffset).toBe(0)
+  expect(after.coreOffset).toBe(0)
+  expect(after.rangeStart).toBeLessThan(10)
+  expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
+  expect(after.domScrollEvents).toBe(before.domScrollEvents)
+  expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
+  expect(after.offsetCallbackSources.at(-1)).toBe("observer")
+  expect(after.syntheticScrollDispatches).toBe(0)
+})
+
+test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
+  page,
+}) => {
+  const before = await prepare(page)
+  const beforeRows = before.domIndexes
+  expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
+  expect(before.logicalSurfaceHeight).toBe(80_000)
+  expect(before.renderedSurfaceHeight).toBe(80_000)
+  expect(before.viewportClientHeight).toBe(600)
+  expect(before.viewportScrollHeight).toBe(80_000)
+  expect(before.rangeStart).toBeGreaterThan(1_900)
+  expect(before.nativeOffset).toBe(before.coreOffset)
+  expect(before.visibleRows).toBeGreaterThan(0)
+
+  await triggerBaselineSuspension(page)
+  const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
+  expect(pending.resourceState).toBe("refreshing")
+  expect(pending.routeConnected).toBe(false)
+  expect(pending.viewportConnected).toBe(false)
+  expect(pending.viewportOwnedByRoute).toBe(true)
+  expect(pending.nativeOffset).toBe(0)
+  expect(pending.coreOffset).toBe(before.coreOffset)
+  expect(pending.rangeStart).toBe(before.rangeStart)
+  expect(pending.rangeEnd).toBe(before.rangeEnd)
+  expect(pending.indexes).toEqual(before.indexes)
+  expect(pending.domIndexes).toEqual(beforeRows)
+  expect(pending.sameMountedRows).toBe(true)
+  expect(pending.domScrollEvents).toBe(before.domScrollEvents)
+  expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
+  expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
+  expect(pending.mutationEvents).toHaveLength(1)
+  expect(pending.mutationEvents[0]).toMatchObject({
+    kind: "removed",
+    routeConnectedInCallback: false,
+    nativeOffsetInCallback: 0,
+  })
+  expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
+  expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
+
+  const after = await resolveSuspension(page)
+  expect(after.resourceState).toBe("ready")
+  expect(after.routeConnected).toBe(true)
+  expect(after.viewportConnected).toBe(true)
+  expect(after.viewportOwnedByRoute).toBe(true)
+  expect(after.sameRoute).toBe(true)
+  expect(after.sameViewport).toBe(true)
+  expect(after.sameSurface).toBe(true)
+  expect(after.sameMountedRows).toBe(true)
+  expect(after.nativeOffset).toBe(0)
+  expect(after.coreOffset).toBe(before.coreOffset)
+  expect(after.rangeStart).toBe(before.rangeStart)
+  expect(after.rangeEnd).toBe(before.rangeEnd)
+  expect(after.indexes).toEqual(before.indexes)
+  expect(after.domIndexes).toEqual(beforeRows)
+  expect(after.domScrollEvents).toBe(before.domScrollEvents)
+  expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
+  expect(after.mutationEvents).toHaveLength(2)
+  expect(after.mutationEvents[1]).toMatchObject({
+    kind: "added",
+    routeConnectedInCallback: true,
+    nativeOffsetInCallback: 0,
+  })
+  expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
+  expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
+  expect(after.visibleRows).toBe(0)
+  expect(after.minimumRowTop).toBeGreaterThan(50_000)
+  expect(after.syntheticScrollDispatches).toBe(0)
+
+  await page.locator("[data-viewport]").hover()
+  await page.mouse.wheel(0, 80)
+  await expect
+    .poll(() =>
+      page.evaluate(() => {
+        const value = window.timelineSuspense.snapshot()
+        return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
+      }),
+    )
+    .toBe(true)
+  await page.evaluate(() => window.timelineSuspense.frames(2))
+  const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
+  expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
+  expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
+  expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
+  expect(recovered.lastScrollTrusted).toBe(true)
+  expect(recovered.rangeStart).toBeLessThan(10)
+  expect(recovered.visibleRows).toBeGreaterThan(0)
+})
+
+test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
+  await page.goto("/?resource=guard")
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
+  const before = await prepare(page)
+
+  await page.evaluate(() => window.timelineSuspense.trigger())
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
+  await page.evaluate(() => window.timelineSuspense.frames(3))
+  const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
+  expect(pending.routeConnected).toBe(true)
+  expect(pending.mutationEvents).toEqual([])
+  expect(pending.nativeOffset).toBe(before.nativeOffset)
+  expect(pending.coreOffset).toBe(before.coreOffset)
+  expect(pending.visibleRows).toBeGreaterThan(0)
+
+  const after = await resolveSuspension(page)
+  expect(after.routeConnected).toBe(true)
+  expect(after.nativeOffset).toBe(before.nativeOffset)
+  expect(after.coreOffset).toBe(before.coreOffset)
+  expect(after.visibleRows).toBeGreaterThan(0)
+})
+
+async function prepare(page: Page) {
+  const before = await page.evaluate(() => window.timelineSuspense.prepare())
+  expect(before.routeConnected).toBe(true)
+  expect(before.viewportConnected).toBe(true)
+  expect(before.viewportOwnedByRoute).toBe(true)
+  expect(before.sameMountedRows).toBe(true)
+  expect(before.rangeStart).toBeGreaterThan(1_900)
+  expect(before.nativeOffset).toBe(before.coreOffset)
+  return before
+}
+
+async function triggerBaselineSuspension(page: Page) {
+  await page.evaluate(() => window.timelineSuspense.trigger())
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
+  await page.evaluate(() => window.timelineSuspense.frames(3))
+}
+
+async function resolveSuspension(page: Page) {
+  await page.evaluate(() => window.timelineSuspense.resolve())
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
+  await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
+  await page.evaluate(() => window.timelineSuspense.frames(3))
+  return page.evaluate(() => window.timelineSuspense.snapshot())
+}
+
+function diagnostic(value: unknown) {
+  return JSON.stringify(value, null, 2)
+}

+ 7 - 0
packages/app/e2e/reproduction/timeline-suspense/vite.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from "vite"
+import solid from "vite-plugin-solid"
+
+export default defineConfig({
+  root: import.meta.dirname,
+  plugins: [solid()],
+})

+ 1 - 1
packages/app/e2e/smoke/session-timeline.spec.ts

@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
 }
 }
 
 
 async function expectSessionReady(page: Page) {
 async function expectSessionReady(page: Page) {
-  await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
+  await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
 }
 }

+ 3 - 0
packages/app/e2e/tsconfig.json

@@ -10,6 +10,9 @@
     "./performance/timeline-stability/fixture.test.ts",
     "./performance/timeline-stability/fixture.test.ts",
     "./performance/timeline-stability/fixture.ts",
     "./performance/timeline-stability/fixture.ts",
     "./performance/unit/visual-stability.test.ts",
     "./performance/unit/visual-stability.test.ts",
+    "./reproduction/timeline-suspense/**/*.ts",
+    "./reproduction/timeline-suspense/**/*.tsx",
+    "../src/pages/session/timeline/observe-element-offset.ts",
     "./regression/new-session-panel-corner.spec.ts",
     "./regression/new-session-panel-corner.spec.ts",
     "./regression/session-timeline-context-resize.spec.ts",
     "./regression/session-timeline-context-resize.spec.ts",
     "./utils/**/*.ts"
     "./utils/**/*.ts"

+ 66 - 2
packages/app/e2e/utils/mock-server.ts

@@ -1,7 +1,7 @@
 import type { Page, Route } from "@playwright/test"
 import type { Page, Route } from "@playwright/test"
 
 
 const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
 const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
-const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
+const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
 
 
 export interface MockServerConfig {
 export interface MockServerConfig {
   provider: unknown
   provider: unknown
@@ -11,7 +11,10 @@ export interface MockServerConfig {
   pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
   pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
   vcsDiff?: unknown[]
   vcsDiff?: unknown[]
   messageDelay?: number
   messageDelay?: number
+  beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
   onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
   onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
+  message?: (sessionID: string, messageID: string) => unknown
+  onMessage?: (input: { sessionID: string; messageID: string }) => void
   events?: () => unknown[]
   events?: () => unknown[]
   eventRetry?: number
   eventRetry?: number
   todos?: (sessionID: string) => unknown[]
   todos?: (sessionID: string) => unknown[]
@@ -19,6 +22,7 @@ export interface MockServerConfig {
   questions?: unknown[] | (() => unknown[])
   questions?: unknown[] | (() => unknown[])
   fileList?: (path: string) => unknown | Promise<unknown>
   fileList?: (path: string) => unknown | Promise<unknown>
   fileContent?: (path: string) => unknown | Promise<unknown>
   fileContent?: (path: string) => unknown | Promise<unknown>
+  findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
   sessionStatus?: unknown
   sessionStatus?: unknown
 }
 }
 
 
@@ -52,6 +56,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     const path = url.pathname
     const path = url.pathname
     if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
     if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
     if (path === "/global/health") return json(route, { healthy: true })
     if (path === "/global/health") return json(route, { healthy: true })
+    if (path === "/api/session")
+      return json(route, {
+        data: config.sessions.map((session) => v2Session(session, config.directory)),
+        cursor: {},
+      })
+    if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
     if (path === "/permission")
     if (path === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
     if (path === "/question")
@@ -62,6 +72,23 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
       return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
     if (path === "/file/content" && config.fileContent)
     if (path === "/file/content" && config.fileContent)
       return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
       return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
+    if (path === "/find/file" && config.findFiles)
+      return json(
+        route,
+        await config.findFiles({
+          query: url.searchParams.get("query") ?? "",
+          dirs: url.searchParams.get("dirs") ?? undefined,
+          limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
+        }),
+      )
+    if (path === "/api/reference")
+      return json(route, {
+        location: {
+          directory: config.directory,
+          project: { id: (config.project as { id?: string }).id, directory: config.directory },
+        },
+        data: [],
+      })
     if (emptyObject.has(path)) return json(route, {})
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
     if (emptyList.has(path)) return json(route, [])
     if (path in staticRoutes) return json(route, staticRoutes[path])
     if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -72,6 +99,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, session ?? {})
       return json(route, session ?? {})
     }
     }
 
 
+    const projectMatch = path.match(/^\/project\/([^/]+)$/)
+    if (projectMatch) return json(route, config.project)
+
+    const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
+    if (messageMatch) {
+      config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
+      if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
+      return json(route, message)
+    }
+
     const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
     const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
     if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
     if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
     if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
     if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
@@ -82,7 +121,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       const before = token ? cursors.get(token) : undefined
       const before = token ? cursors.get(token) : undefined
       if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
       if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
-      if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
       const limit = Number(url.searchParams.get("limit") ?? 80)
       const limit = Number(url.searchParams.get("limit") ?? 80)
       const pageData = config.pageMessages(messagesMatch[1], limit, before)
       const pageData = config.pageMessages(messagesMatch[1], limit, before)
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
@@ -97,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
   })
   })
 }
 }
 
 
+function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
+  const time = session.time && typeof session.time === "object" ? session.time : {}
+  return {
+    id: session.id,
+    parentID: session.parentID,
+    projectID: session.projectID ?? "project",
+    cost: session.cost ?? 0,
+    tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    time: {
+      created: "created" in time && typeof time.created === "number" ? time.created : 0,
+      updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
+      ...(session.time && typeof session.time === "object" && "archived" in session.time
+        ? { archived: session.time.archived }
+        : {}),
+    },
+    title: session.title ?? session.id,
+    location: {
+      directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
+      ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
+    },
+    ...(typeof session.path === "string" ? { subpath: session.path } : {}),
+  }
+}
+
 function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
 function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
   return route.fulfill({
   return route.fulfill({
     status,
     status,

+ 1 - 1
packages/app/e2e/utils/sse-transport.ts

@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
         const request = new Request(input, init)
         const request = new Request(input, init)
         const url = new URL(request.url)
         const url = new URL(request.url)
         if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
         if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
-          return originalFetch(input, init)
+          return originalFetch(request)
 
 
         const id = ++nextConnectionID
         const id = ++nextConnectionID
         const record = {
         const record = {

+ 3 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/app",
   "name": "@opencode-ai/app",
-  "version": "1.17.13",
+  "version": "1.18.3",
   "description": "",
   "description": "",
   "type": "module",
   "type": "module",
   "exports": {
   "exports": {
@@ -47,6 +47,7 @@
     "vite-plugin-solid": "catalog:"
     "vite-plugin-solid": "catalog:"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@corvu/drawer": "catalog:",
     "@dnd-kit/abstract": "0.5.0",
     "@dnd-kit/abstract": "0.5.0",
     "@dnd-kit/dom": "0.5.0",
     "@dnd-kit/dom": "0.5.0",
     "@dnd-kit/helpers": "0.5.0",
     "@dnd-kit/helpers": "0.5.0",
@@ -88,6 +89,7 @@
     "shiki": "catalog:",
     "shiki": "catalog:",
     "solid-js": "catalog:",
     "solid-js": "catalog:",
     "solid-list": "catalog:",
     "solid-list": "catalog:",
+    "solid-presence": "0.2.0",
     "tailwindcss": "catalog:"
     "tailwindcss": "catalog:"
   }
   }
 }
 }

+ 148 - 75
packages/app/src/app.tsx

@@ -9,9 +9,19 @@ import { Font } from "@opencode-ai/ui/font"
 import { Splash } from "@opencode-ai/ui/logo"
 import { Splash } from "@opencode-ai/ui/logo"
 import { ThemeProvider } from "@opencode-ai/ui/theme/context"
 import { ThemeProvider } from "@opencode-ai/ui/theme/context"
 import { MetaProvider } from "@solidjs/meta"
 import { MetaProvider } from "@solidjs/meta"
-import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
+import {
+  type BaseRouterProps,
+  Navigate,
+  Route,
+  Router,
+  useLocation,
+  useNavigate,
+  useParams,
+  useSearchParams,
+} from "@solidjs/router"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { Effect } from "effect"
 import { Effect } from "effect"
+import { base64Encode } from "@opencode-ai/core/util/encode"
 import {
 import {
   type Component,
   type Component,
   createEffect,
   createEffect,
@@ -28,11 +38,12 @@ import {
   Show,
   Show,
 } from "solid-js"
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import { Dynamic } from "solid-js/web"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
 import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
 import { FileProvider } from "@/context/file"
 import { ServerSDKProvider } from "@/context/server-sdk"
 import { ServerSDKProvider } from "@/context/server-sdk"
-import { ServerSyncProvider } from "@/context/server-sync"
+import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
 import { GlobalProvider, useGlobal } from "@/context/global"
 import { GlobalProvider, useGlobal } from "@/context/global"
 import { HighlightsProvider } from "@/context/highlights"
 import { HighlightsProvider } from "@/context/highlights"
 import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
 import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,9 +63,10 @@ import LegacyLayout from "@/pages/layout"
 import NewLayout from "@/pages/layout-new"
 import NewLayout from "@/pages/layout-new"
 import { ErrorPage } from "./pages/error"
 import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
 import { useCheckServerHealth } from "./utils/server-health"
-import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
+import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
+import { createSessionLineage } from "@/pages/session/session-lineage"
 
 
-import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
+import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
 import { NewHome, LegacyHome } from "@/pages/home"
 import { NewHome, LegacyHome } from "@/pages/home"
 
 
 const NewSession = lazy(() => import("@/pages/new-session"))
 const NewSession = lazy(() => import("@/pages/new-session"))
@@ -88,10 +100,14 @@ const SessionRoute = () => {
     tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
     tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
   })
   })
 
 
-  return <SessionPage />
+  return (
+    <SessionRouteErrorBoundary sessionID={params.id}>
+      <SessionPage />
+    </SessionRouteErrorBoundary>
+  )
 }
 }
 
 
-const TargetSessionRoute = () => {
+function TargetServerRoute(props: ParentProps) {
   const params = useParams<{ serverKey: string; id: string }>()
   const params = useParams<{ serverKey: string; id: string }>()
   const global = useGlobal()
   const global = useGlobal()
   const conn = createMemo(() => {
   const conn = createMemo(() => {
@@ -105,17 +121,49 @@ const TargetSessionRoute = () => {
     // re-resolves reactively instead); both rely on this key for server changes.
     // re-resolves reactively instead); both rely on this key for server changes.
     <Show when={requireServerKey(params.serverKey)} keyed>
     <Show when={requireServerKey(params.serverKey)} keyed>
       <ServerSDKProvider server={conn}>
       <ServerSDKProvider server={conn}>
-        <ServerSyncProvider server={conn}>
-          <TargetSessionRouteContent />
-        </ServerSyncProvider>
+        <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
       </ServerSDKProvider>
       </ServerSDKProvider>
     </Show>
     </Show>
   )
   )
 }
 }
 
 
+const TargetSessionRoute = () => (
+  <TargetServerRoute>
+    <TargetSessionRouteContent />
+  </TargetServerRoute>
+)
+
+function LegacyTargetSessionRoute() {
+  const params = useParams<{ serverKey: string; id: string }>()
+  return (
+    <TargetServerRoute>
+      <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
+        <LegacyTargetSessionRedirect />
+      </SessionRouteErrorBoundary>
+    </TargetServerRoute>
+  )
+}
+
+function LegacyTargetSessionRedirect() {
+  const params = useParams<{ id: string }>()
+  const navigate = useNavigate()
+  const sync = useServerSync()
+  const current = createSessionLineage(
+    () => params.id,
+    () => sync().session.lineage,
+  )
+
+  createEffect(() => {
+    const directory = current()?.session.directory
+    if (!directory) return
+    navigate(legacySessionHref(directory, params.id), { replace: true })
+  })
+
+  return null
+}
+
 // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
 // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
-// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
-// Notification/Models + the visual Layout) for that server.
+// server via ServerKey, then provide the server-scoped shell for that server.
 function SelectedServerProviders(props: ParentProps) {
 function SelectedServerProviders(props: ParentProps) {
   return (
   return (
     <ServerKey>
     <ServerKey>
@@ -126,16 +174,17 @@ function SelectedServerProviders(props: ParentProps) {
   )
   )
 }
 }
 
 
-function LegacyServerLayout(props: ParentProps) {
+function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
   return (
   return (
     <SelectedServerProviders>
     <SelectedServerProviders>
-      <LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
+      <LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
     </SelectedServerProviders>
     </SelectedServerProviders>
   )
   )
 }
 }
 
 
 function DraftRoute() {
 function DraftRoute() {
   const [search] = useSearchParams<{ draftId?: string }>()
   const [search] = useSearchParams<{ draftId?: string }>()
+  const settings = useSettings()
   const tabs = useTabs()
   const tabs = useTabs()
   return (
   return (
     <Show when={tabs.ready()}>
     <Show when={tabs.ready()}>
@@ -144,7 +193,14 @@ function DraftRoute() {
         keyed
         keyed
         fallback={<Navigate href="/" />}
         fallback={<Navigate href="/" />}
       >
       >
-        {(draft) => <ResolvedDraftRoute draft={draft} />}
+        {(draft) => (
+          <Show
+            when={settings.general.newLayoutDesigns()}
+            fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
+          >
+            <ResolvedDraftRoute draft={draft} />
+          </Show>
+        )}
       </Show>
       </Show>
     </Show>
     </Show>
   )
   )
@@ -160,7 +216,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
     <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
     <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
       <ServerSDKProvider server={conn}>
       <ServerSDKProvider server={conn}>
         <ServerSyncProvider server={conn}>
         <ServerSyncProvider server={conn}>
-          <DraftServerScopedProviders directory={directory}>
+          <ModelsProvider directory={directory}>
             <SDKProvider directory={directory}>
             <SDKProvider directory={directory}>
               <DirectoryDataProvider directory={directory} server={serverKey}>
               <DirectoryDataProvider directory={directory} server={serverKey}>
                 <DraftProviders>
                 <DraftProviders>
@@ -168,7 +224,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
                 </DraftProviders>
                 </DraftProviders>
               </DirectoryDataProvider>
               </DirectoryDataProvider>
             </SDKProvider>
             </SDKProvider>
-          </DraftServerScopedProviders>
+          </ModelsProvider>
         </ServerSyncProvider>
         </ServerSyncProvider>
       </ServerSDKProvider>
       </ServerSDKProvider>
     </Show>
     </Show>
@@ -186,7 +242,7 @@ declare global {
       deepLinks?: string[]
       deepLinks?: string[]
     }
     }
     api?: {
     api?: {
-      setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
+      setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
       exportDebugLogs?: () => Promise<string>
       exportDebugLogs?: () => Promise<string>
     }
     }
   }
   }
@@ -262,45 +318,36 @@ function DesktopCommands() {
 // Server-scoped providers shared by the legacy shell and the top-level new shell.
 // Server-scoped providers shared by the legacy shell and the top-level new shell.
 type ServerScopedShellProps = ParentProps<{
 type ServerScopedShellProps = ParentProps<{
   directory?: () => string | undefined
   directory?: () => string | undefined
-  sessionID?: () => string | undefined
+  serverScoped?: JSX.Element
 }>
 }>
 
 
 function ServerScopedProviders(props: ServerScopedShellProps) {
 function ServerScopedProviders(props: ServerScopedShellProps) {
   return (
   return (
-    <PermissionProvider directory={props.directory}>
-      <LayoutProvider>
-        <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
-      </LayoutProvider>
-    </PermissionProvider>
+    <LayoutProvider>
+      {props.serverScoped}
+      <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
+    </LayoutProvider>
   )
   )
 }
 }
 
 
 function LegacyServerScopedShell(props: ServerScopedShellProps) {
 function LegacyServerScopedShell(props: ServerScopedShellProps) {
   return (
   return (
-    <ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
+    <ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
       <LegacyLayout>{props.children}</LegacyLayout>
       <LegacyLayout>{props.children}</LegacyLayout>
     </ServerScopedProviders>
     </ServerScopedProviders>
   )
   )
 }
 }
 
 
-function NewAppLayout(props: ParentProps) {
+function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
   return (
   return (
     <SelectedServerProviders>
     <SelectedServerProviders>
-      <ServerScopedProviders>
+      <ServerScopedProviders serverScoped={props.serverScoped}>
         <NewLayout>{props.children}</NewLayout>
         <NewLayout>{props.children}</NewLayout>
       </ServerScopedProviders>
       </ServerScopedProviders>
     </SelectedServerProviders>
     </SelectedServerProviders>
   )
   )
 }
 }
 
 
-function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
-  return (
-    <PermissionProvider directory={props.directory}>
-      <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
-    </PermissionProvider>
-  )
-}
-
 // The draft page only renders the prompt composer, so it drops TerminalProvider.
 // The draft page only renders the prompt composer, so it drops TerminalProvider.
 // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
 // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
 function DraftProviders(props: ParentProps) {
 function DraftProviders(props: ParentProps) {
@@ -318,8 +365,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
     <MetaProvider>
     <MetaProvider>
       <Font />
       <Font />
       <ThemeProvider
       <ThemeProvider
-        onThemeApplied={(_, mode) => {
-          void window.api?.setTitlebar?.({ mode })
+        onThemeApplied={(_, mode, scheme) => {
+          void window.api?.setTitlebar?.({ mode, scheme })
         }}
         }}
       >
       >
         <LanguageProvider locale={props.locale}>
         <LanguageProvider locale={props.locale}>
@@ -347,7 +394,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
   )
   )
 }
 }
 
 
-function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
+function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
   const server = useServer()
   const server = useServer()
   const checkServerHealth = useCheckServerHealth()
   const checkServerHealth = useCheckServerHealth()
 
 
@@ -376,34 +423,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
   const checking = createMemo(
   const checking = createMemo(
     () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
     () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
   )
   )
+  const [startup] = createResource(async () => {
+    if (!props.startup) return true
+    await props.startup.catch((error) => {
+      console.error("[startup] startup gate failed", error)
+    })
+    return true
+  })
+  const startupChecking = createMemo(
+    () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
+  )
+  const loading = createMemo(() => checking() || startupChecking())
 
 
   return (
   return (
-    <Show
-      when={!checking()}
-      fallback={
-        <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
+    <>
+      <Show when={!checking()}>
+        <Show
+          when={startupHealthCheck.latest}
+          fallback={
+            <ConnectionError
+              onRetry={() => {
+                if (checkMode() === "background") void healthCheckActions.refetch()
+              }}
+              onServerSelected={(key) => {
+                setCheckMode("blocking")
+                server.setActive(key)
+                void healthCheckActions.refetch()
+              }}
+            />
+          }
+        >
+          {props.children}
+        </Show>
+      </Show>
+      <Show when={loading()}>
+        <div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
           <Splash class="w-16 h-20 opacity-50 animate-pulse" />
           <Splash class="w-16 h-20 opacity-50 animate-pulse" />
         </div>
         </div>
-      }
-    >
-      <Show
-        when={startupHealthCheck.latest}
-        fallback={
-          <ConnectionError
-            onRetry={() => {
-              if (checkMode() === "background") void healthCheckActions.refetch()
-            }}
-            onServerSelected={(key) => {
-              setCheckMode("blocking")
-              server.setActive(key)
-              void healthCheckActions.refetch()
-            }}
-          />
-        }
-      >
-        {props.children}
       </Show>
       </Show>
-    </Show>
+    </>
   )
   )
 }
 }
 
 
@@ -470,6 +528,8 @@ export function AppInterface(props: {
   servers?: Array<ServerConnection.Any>
   servers?: Array<ServerConnection.Any>
   router?: Component<BaseRouterProps>
   router?: Component<BaseRouterProps>
   disableHealthCheck?: boolean
   disableHealthCheck?: boolean
+  startup?: Promise<void>
+  serverScoped?: JSX.Element
 }) {
 }) {
   // The visual new layout lives in the router root so it remains mounted across
   // The visual new layout lives in the router root so it remains mounted across
   // route changes. Draft and session routes override only their server-bound data
   // route changes. Draft and session routes override only their server-bound data
@@ -491,23 +551,25 @@ export function AppInterface(props: {
     >
     >
       <GlobalProvider>
       <GlobalProvider>
         <SettingsProvider>
         <SettingsProvider>
-          <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
+          <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
             <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
             <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
               <Dynamic
               <Dynamic
                 component={props.router ?? Router}
                 component={props.router ?? Router}
                 root={(routerProps) => (
                 root={(routerProps) => (
                   <TabsProvider>
                   <TabsProvider>
-                    <NotificationProvider>
-                      <ServerShell>
-                        <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
-                          <NewAppLayout>{routerProps.children}</NewAppLayout>
-                        </Show>
-                      </ServerShell>
-                    </NotificationProvider>
+                    <PermissionProvider>
+                      <NotificationProvider>
+                        <ServerShell>
+                          <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
+                            <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
+                          </Show>
+                        </ServerShell>
+                      </NotificationProvider>
+                    </PermissionProvider>
                   </TabsProvider>
                   </TabsProvider>
                 )}
                 )}
               >
               >
-                <Routes />
+                <Routes serverScoped={props.serverScoped} />
               </Dynamic>
               </Dynamic>
             </Show>
             </Show>
           </ConnectionGate>
           </ConnectionGate>
@@ -517,13 +579,24 @@ export function AppInterface(props: {
   )
   )
 }
 }
 
 
-function Routes() {
+function Routes(props: { serverScoped?: JSX.Element }) {
   const settings = useSettings()
   const settings = useSettings()
 
 
   return (
   return (
     <>
     <>
-      <Route component={LegacyServerLayout}>
-        <Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
+      <Route
+        component={(routeProps) => (
+          <LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
+        )}
+      >
+        <Show when={!settings.general.newLayoutDesigns()}>
+          {
+            <>
+              <Route path="/" component={LegacyHome} />
+              <Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
+            </>
+          }
+        </Show>
         <Route path="/:dir" component={DirectoryLayout}>
         <Route path="/:dir" component={DirectoryLayout}>
           <Route path="/" component={() => <Navigate href="session" />} />
           <Route path="/" component={() => <Navigate href="session" />} />
           <Route path="/session/:id?" component={SessionRoute} />
           <Route path="/session/:id?" component={SessionRoute} />
@@ -531,15 +604,15 @@ function Routes() {
       </Route>
       </Route>
       <Show when={settings.general.newLayoutDesigns()}>
       <Show when={settings.general.newLayoutDesigns()}>
         <Route path="/" component={NewHome} />
         <Route path="/" component={NewHome} />
-        <Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
+        <Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
+        <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
       </Show>
       </Show>
       <Route path="/new-session" component={DraftRoute} />
       <Route path="/new-session" component={DraftRoute} />
-      <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
     </>
     </>
   )
   )
 }
 }
 
 
-function LegacyTargetSessionRoute() {
+function NewLayoutLegacySessionRedirect() {
   const server = useServer()
   const server = useServer()
   const tabs = useTabs()
   const tabs = useTabs()
   const params = useParams<{ id: string }>()
   const params = useParams<{ id: string }>()

BIN
packages/app/src/assets/help/home.png


BIN
packages/app/src/assets/help/introducing-tabs.mp4


BIN
packages/app/src/assets/help/placeholder.png


BIN
packages/app/src/assets/help/tabs.png


+ 279 - 0
packages/app/src/components/command-palette.ts

@@ -0,0 +1,279 @@
+import { getFilename } from "@opencode-ai/core/util/path"
+import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createMemo, onCleanup } from "solid-js"
+import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
+import { useFile } from "@/context/file"
+import { useGlobal } from "@/context/global"
+import { useLanguage } from "@/context/language"
+import { useLayout, type LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { useServerSDK } from "@/context/server-sdk"
+import { useTabs } from "@/context/tabs"
+import { displayName, projectForSession } from "@/pages/layout/helpers"
+import { createSessionTabs } from "@/pages/session/helpers"
+import { useSessionLayout } from "@/pages/session/session-layout"
+
+export type CommandPaletteEntry = {
+  id: string
+  type: "command" | "file" | "session"
+  title: string
+  description?: string
+  keybind?: string
+  category: string
+  option?: CommandOption
+  path?: string
+  directory?: string
+  sessionID?: string
+  server?: ServerConnection.Key
+  project?: LocalProject
+  archived?: number
+  updated?: number
+}
+
+const ENTRY_LIMIT = 5
+const COMMON_COMMAND_IDS = [
+  "session.new",
+  "workspace.new",
+  "session.previous",
+  "session.next",
+  "terminal.toggle",
+  "review.toggle",
+] as const
+
+export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
+  const seen = new Set<string>()
+  return items.filter((item) => {
+    if (seen.has(item.id)) return false
+    seen.add(item.id)
+    return true
+  })
+}
+
+export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
+  return {
+    id: "file:" + path,
+    type: "file",
+    title: path,
+    category,
+    path,
+  }
+}
+
+export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
+  const file = useFile()
+  const layout = useLayout()
+  const { tabs, view } = useSessionLayout()
+
+  return (path: string) => {
+    const value = file.tab(path)
+    void tabs().open(value)
+    void file.load(path)
+    if (!view().reviewPanel.opened()) view().reviewPanel.open()
+    layout.fileTree.setTab("all")
+    onOpenFile?.(path)
+    tabs().setActive(value)
+  }
+}
+
+export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
+  const command = useCommand()
+  const global = useGlobal()
+  const language = useLanguage()
+  const file = useFile()
+  const dialog = useDialog()
+  const serverSDK = useServerSDK()()
+  const serverCtx = global.ensureServerCtx(serverSDK.server)
+  const appTabs = useTabs()
+  const { tabs: sessionTabs } = useSessionLayout()
+  const openFile = createCommandPaletteFileOpener(props.onOpenFile)
+  const state = { cleanup: undefined as (() => void) | void, committed: false }
+  const filesOnly = () => props.filesOnly?.() ?? false
+
+  const allowedCommands = createMemo(() => {
+    if (filesOnly()) return []
+    return commandPaletteOptions(command.options)
+  })
+  const commandEntries = createMemo(() => {
+    const category = language.t("palette.group.commands")
+    return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
+  })
+  const preferredCommandEntries = createMemo(() => {
+    const all = allowedCommands()
+    const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
+    const picked = all.filter((option) => order.has(option.id))
+    const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
+    const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
+    const category = language.t("palette.group.commands")
+    return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
+  })
+
+  const tabState = createSessionTabs({
+    tabs: sessionTabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+  })
+  const recentFileEntries = createMemo(() => {
+    const all = tabState.openedTabs()
+    const active = tabState.activeFileTab()
+    const order = active ? [active, ...all.filter((item) => item !== active)] : all
+    const seen = new Set<string>()
+    const category = language.t("palette.group.files")
+    return order
+      .map((item) => file.pathFromTab(item))
+      .filter((path): path is string => {
+        if (!path || seen.has(path)) return false
+        seen.add(path)
+        return true
+      })
+      .slice(0, ENTRY_LIMIT)
+      .map((path) => createCommandPaletteFileEntry(path, category))
+  })
+  const rootFileEntries = createMemo(() => {
+    const category = language.t("palette.group.files")
+    return file.tree
+      .children("")
+      .filter((node) => node.type === "file")
+      .map((node) => node.path)
+      .sort((a, b) => a.localeCompare(b))
+      .slice(0, ENTRY_LIMIT)
+      .map((path) => createCommandPaletteFileEntry(path, category))
+  })
+
+  const sessions = createServerSessionEntries({
+    server: ServerConnection.key(serverSDK.server),
+    opened: serverCtx.projects.list,
+    stored: () => serverCtx.sync.data.project,
+    load: (search, signal) =>
+      serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
+    untitled: () => language.t("command.session.new"),
+    category: () => language.t("command.category.session"),
+  })
+
+  const highlight = (item: CommandPaletteEntry | undefined) => {
+    state.cleanup?.()
+    state.cleanup = undefined
+    if (item?.type !== "command") return
+    state.cleanup = item.option?.onHighlight?.()
+  }
+
+  const select = (item: CommandPaletteEntry | undefined) => {
+    if (!item) return
+    state.committed = true
+    state.cleanup = undefined
+    dialog.close()
+    if (item.type === "command") {
+      item.option?.onSelect?.("palette")
+      return
+    }
+    if (item.type === "session") {
+      if (!item.sessionID || !item.server) return
+      const directory = item.project?.worktree ?? item.directory
+      if (directory) {
+        serverCtx.projects.open(directory)
+        serverCtx.projects.touch(directory)
+      }
+      const tab = appTabs.addSessionTab({
+        server: item.server,
+        sessionId: item.sessionID,
+      })
+      appTabs.select(tab)
+      return
+    }
+    if (!item.path) return
+    openFile(item.path)
+  }
+
+  onCleanup(() => {
+    if (state.committed) return
+    state.cleanup?.()
+  })
+
+  return {
+    language,
+    file,
+    commandEntries,
+    preferredCommandEntries,
+    recentFileEntries,
+    rootFileEntries,
+    sessions,
+    highlight,
+    select,
+    close: () => dialog.close(),
+  }
+}
+
+export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
+  return {
+    id: "command:" + option.id,
+    type: "command",
+    title: option.title,
+    description: option.description,
+    keybind: option.keybind,
+    category,
+    option,
+  }
+}
+
+export function createServerSessionEntries(props: {
+  server: ServerConnection.Key
+  opened: () => LocalProject[]
+  stored: () => Project[]
+  load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
+  untitled: () => string
+  category: () => string
+}) {
+  let abort: AbortController | undefined
+
+  onCleanup(() => abort?.abort())
+
+  return async (text: string): Promise<CommandPaletteEntry[]> => {
+    const search = text.trim()
+    if (!search) {
+      abort?.abort()
+      return []
+    }
+    abort?.abort()
+    const current = new AbortController()
+    abort = current
+    await new Promise<void>((resolve) => {
+      const timer = setTimeout(resolve, 100)
+      current.signal.addEventListener(
+        "abort",
+        () => {
+          clearTimeout(timer)
+          resolve()
+        },
+        { once: true },
+      )
+    })
+    if (current.signal.aborted) return []
+    const opened = props.opened()
+    const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
+    const stored = props.stored().map((project) => ({ ...project, expanded: false }))
+    const storedByID = new Map(stored.map((project) => [project.id, project] as const))
+    return props
+      .load(search, current.signal)
+      .then((result) =>
+        (result.data ?? [])
+          .filter((session) => !session.time.archived)
+          .map((session) => {
+            const project =
+              projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
+            return {
+              id: `session:${props.server}:${session.id}`,
+              type: "session" as const,
+              title: session.title || props.untitled(),
+              description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
+              category: props.category(),
+              directory: session.directory,
+              sessionID: session.id,
+              server: props.server,
+              project,
+              updated: session.time.updated,
+            }
+          }),
+      )
+      .catch(() => [] as CommandPaletteEntry[])
+  }
+}

+ 53 - 1
packages/app/src/components/debug-bar.tsx

@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 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 { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
 
 
 type Mem = Performance & {
 type Mem = Performance & {
   memory?: {
   memory?: {
@@ -107,8 +108,45 @@ function Cell(props: {
   )
   )
 }
 }
 
 
+function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
+  const content = () => (
+    <button
+      type="button"
+      aria-label="Force focus styles on all interactive elements"
+      aria-pressed={props.active}
+      classList={{
+        "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
+        "min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
+        "min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
+        "bg-surface-raised-base text-text-strong": props.active,
+      }}
+      onClick={props.onClick}
+    >
+      <span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
+      <span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
+        {props.active ? "ON" : "OFF"}
+      </span>
+    </button>
+  )
+
+  if (props.inline) {
+    return (
+      <TooltipV2 value="Force focus styles on all interactive elements" placement="top">
+        {content()}
+      </TooltipV2>
+    )
+  }
+
+  return (
+    <Tooltip value="Force focus styles on all interactive elements" placement="top">
+      {content()}
+    </Tooltip>
+  )
+}
+
 export function DebugBar(props: { inline?: boolean } = {}) {
 export function DebugBar(props: { inline?: boolean } = {}) {
   const language = useLanguage()
   const language = useLanguage()
+  const platform = usePlatform()
   const location = useLocation()
   const location = useLocation()
   const routing = useIsRouting()
   const routing = useIsRouting()
   const [state, setState] = createStore({
   const [state, setState] = createStore({
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
     delay: undefined as number | undefined,
     delay: undefined as number | undefined,
     fps: undefined as number | undefined,
     fps: undefined as number | undefined,
     gap: undefined as number | undefined,
     gap: undefined as number | undefined,
+    focus: false,
     heap: {
     heap: {
       limit: undefined as number | undefined,
       limit: undefined as number | undefined,
       used: undefined as number | undefined,
       used: undefined as number | undefined,
@@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
   }
   }
   const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
   const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
   const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
   const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
+  const toggleFocus = async () => {
+    if (!platform.setForceFocus) return
+    const enabled = !state.focus
+    await platform.setForceFocus(enabled)
+    setState("focus", enabled)
+  }
+
+  onCleanup(() => {
+    if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
+  })
 
 
   let prev = ""
   let prev = ""
   let start = 0
   let start = 0
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
           bad={bad(heap(), 0.8)}
           bad={bad(heap(), 0.8)}
           dim={state.heap.used === undefined}
           dim={state.heap.used === undefined}
           inline={props.inline}
           inline={props.inline}
-          wide
+          wide={!platform.setForceFocus}
         />
         />
+        {platform.setForceFocus && (
+          <FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
+        )}
       </div>
       </div>
     </aside>
     </aside>
   )
   )

+ 220 - 0
packages/app/src/components/dialog-command-palette-v2.css

@@ -0,0 +1,220 @@
+.command-palette-v2 {
+  overflow: hidden;
+}
+
+/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
+   top stays put while the content-driven height grows and shrinks. */
+[data-component="dialog-v2"]:has(.command-palette-v2) {
+  align-items: flex-start;
+}
+
+[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
+  width: min(calc(100vw - 24px), 640px);
+  height: auto;
+  min-height: 280px;
+  max-height: min(calc(100vh - 96px), 480px);
+  margin-top: max(48px, calc((100vh - 480px) / 2));
+  border-radius: 12px;
+  background: var(--v2-background-bg-base);
+  box-shadow: var(--v2-elevation-floating);
+}
+
+.command-palette-v2-body {
+  display: flex;
+  min-height: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 0;
+  padding: 0;
+}
+
+.command-palette-v2-search {
+  flex-shrink: 0;
+  padding: 6px;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] {
+  width: 100%;
+  height: 36px;
+  border-radius: 6px;
+  outline: 0;
+  background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
+  box-shadow: none;
+  transition:
+    background-color 120ms ease-in-out,
+    box-shadow 120ms ease-in-out;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
+.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
+  background: var(--v2-background-bg-layer-02);
+  box-shadow: none;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
+  border-radius: 6px;
+  background: transparent;
+  gap: 8px;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
+  padding-left: 12px;
+}
+
+.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
+  border-radius: 6px;
+  background: transparent;
+}
+
+.command-palette-v2-scroll {
+  min-height: 0;
+  flex: 1;
+}
+
+.command-palette-v2-results {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  padding: 6px 6px 8px;
+}
+
+.command-palette-v2-group {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.command-palette-v2-group-title {
+  margin: 6px 0;
+  padding: 0 12px;
+  color: var(--v2-text-text-muted);
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+  user-select: none;
+}
+
+.command-palette-v2-row {
+  display: flex;
+  width: 100%;
+  height: 36px;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 0 12px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--v2-text-text-base);
+  text-align: left;
+  cursor: default;
+  scroll-margin: 6px 0;
+}
+
+.command-palette-v2-row[data-active] {
+  background: var(--v2-overlay-simple-overlay-hover);
+}
+
+.command-palette-v2-row:focus-visible {
+  background: var(--v2-overlay-simple-overlay-hover);
+  outline: none;
+}
+
+.command-palette-v2-row-main {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 8px;
+}
+
+.command-palette-v2-row-icon {
+  flex-shrink: 0;
+  color: var(--v2-icon-icon-muted);
+}
+
+.command-palette-v2-row-text {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 6px;
+}
+
+.command-palette-v2-title {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--v2-text-text-base);
+  font-size: 13px;
+  font-weight: 530;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.command-palette-v2-description,
+.command-palette-v2-meta {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--v2-text-text-muted);
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.command-palette-v2-meta {
+  flex-shrink: 0;
+}
+
+.command-palette-v2-file-path {
+  display: flex;
+  min-width: 0;
+  align-items: baseline;
+  font-size: 13px;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+}
+
+.command-palette-v2-file-dir {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--v2-text-text-muted);
+  font-weight: 440;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.command-palette-v2-file-name {
+  flex-shrink: 0;
+  color: var(--v2-text-text-base);
+  font-weight: 530;
+  white-space: nowrap;
+}
+
+.command-palette-v2-state {
+  display: grid;
+  min-height: 120px;
+  place-items: center;
+  color: var(--v2-text-text-muted);
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 16px;
+  letter-spacing: -0.04px;
+}
+
+@media (max-width: 640px) {
+  .command-palette-v2-row-text {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 1px;
+  }
+
+  .command-palette-v2-description {
+    max-width: 100%;
+  }
+}

+ 343 - 0
packages/app/src/components/dialog-command-palette-v2.tsx

@@ -0,0 +1,343 @@
+import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
+import { Icon } from "@opencode-ai/ui/v2/icon"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
+import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
+import { useGlobal } from "@/context/global"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { useTabs } from "@/context/tabs"
+import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
+import { getRelativeTime } from "@/utils/time"
+import {
+  createCommandPaletteCommandEntry,
+  createCommandPaletteFileEntry,
+  createCommandPaletteModel,
+  createServerSessionEntries,
+  uniqueCommandPaletteEntries,
+  type CommandPaletteEntry,
+} from "./command-palette"
+import "./dialog-command-palette-v2.css"
+
+function groups(entries: CommandPaletteEntry[]) {
+  const map = new Map<string, CommandPaletteEntry[]>()
+  for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
+  return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
+}
+
+function matchesEntry(entry: CommandPaletteEntry, query: string) {
+  const value = query.toLowerCase()
+  return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
+}
+
+export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
+  const palette = createCommandPaletteModel(props)
+  const loadItems = async (text: string) => {
+    const q = text.trim()
+    if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
+
+    const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
+    const category = palette.language.t("palette.group.files")
+    return [
+      ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
+      ...nextSessions,
+      ...files.map((path) => createCommandPaletteFileEntry(path, category)),
+    ]
+  }
+
+  return (
+    <CommandPaletteView
+      placeholder={palette.language.t("palette.search.placeholder")}
+      loadItems={loadItems}
+      highlight={palette.highlight}
+      select={palette.select}
+      close={palette.close}
+    />
+  )
+}
+
+export function DialogHomeCommandPaletteV2(props: {
+  server: ServerConnection.Any
+  onSelectSession: (entry: CommandPaletteEntry) => void
+}) {
+  const command = useCommand()
+  const dialog = useDialog()
+  const global = useGlobal()
+  const language = useLanguage()
+  const serverCtx = global.ensureServerCtx(props.server)
+  const state = { cleanup: undefined as (() => void) | void, committed: false }
+  const commandEntries = createMemo(() => {
+    const category = language.t("palette.group.commands")
+    return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
+  })
+  const sessions = createServerSessionEntries({
+    server: ServerConnection.key(props.server),
+    opened: serverCtx.projects.list,
+    stored: () => serverCtx.sync.data.project,
+    load: (search, signal) =>
+      serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
+    untitled: () => language.t("command.session.new"),
+    category: () => language.t("command.category.session"),
+  })
+
+  const highlight = (item: CommandPaletteEntry | undefined) => {
+    state.cleanup?.()
+    state.cleanup = undefined
+    if (item?.type !== "command") return
+    state.cleanup = item.option?.onHighlight?.()
+  }
+  const select = (item: CommandPaletteEntry | undefined) => {
+    if (!item) return
+    state.committed = true
+    state.cleanup = undefined
+    dialog.close()
+    if (item.type === "command") {
+      item.option?.onSelect?.("palette")
+      return
+    }
+    if (item.type === "session") props.onSelectSession(item)
+  }
+  const loadItems = async (text: string) => {
+    const query = text.trim()
+    if (!query) return commandEntries().slice(0, 5)
+    return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
+  }
+
+  onCleanup(() => {
+    if (state.committed) return
+    state.cleanup?.()
+  })
+
+  return (
+    <CommandPaletteView
+      placeholder={language.t("palette.search.placeholder.home")}
+      loadItems={loadItems}
+      highlight={highlight}
+      select={select}
+      close={() => dialog.close()}
+    />
+  )
+}
+
+function CommandPaletteView(props: {
+  placeholder: string
+  loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
+  highlight: (item: CommandPaletteEntry | undefined) => void
+  select: (item: CommandPaletteEntry | undefined) => void
+  close: () => void
+}) {
+  const language = useLanguage()
+  const tabs = useTabs()
+  const [query, setQuery] = createSignal("")
+  const [active, setActive] = createSignal(0)
+
+  const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
+  // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
+  const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
+  const groupedEntries = createMemo(() => groups(visibleEntries()))
+  const activeEntry = createMemo(() => visibleEntries()[active()])
+  const openSessions = createMemo(
+    () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
+  )
+
+  createEffect(() => {
+    query()
+    visibleEntries()
+    setActive(0)
+  })
+
+  createEffect(() => {
+    props.highlight(activeEntry())
+  })
+
+  let resultsRef: HTMLDivElement | undefined
+
+  const move = (delta: -1 | 1) => {
+    const count = visibleEntries().length
+    if (count === 0) return
+    setActive((index) => (index + delta + count) % count)
+    requestAnimationFrame(() => {
+      resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
+    })
+  }
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "ArrowDown") {
+      event.preventDefault()
+      move(1)
+      return
+    }
+    if (event.key === "ArrowUp") {
+      event.preventDefault()
+      move(-1)
+      return
+    }
+    if (event.key === "Enter") {
+      event.preventDefault()
+      props.select(activeEntry())
+      return
+    }
+    if (event.key === "Escape") {
+      event.preventDefault()
+      props.close()
+    }
+  }
+
+  return (
+    <Dialog class="command-palette-v2" size="large">
+      <DialogBody class="command-palette-v2-body">
+        <div class="command-palette-v2-search">
+          <TextInputV2
+            value={query()}
+            autofocus
+            autocomplete="off"
+            spellcheck={false}
+            appearance="large"
+            placeholder={props.placeholder}
+            leadingIcon={<Icon name="magnifying-glass" />}
+            onInput={(event) => setQuery(event.currentTarget.value)}
+            onKeyDown={handleKeyDown}
+          />
+        </div>
+        <ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
+          <div class="command-palette-v2-results" role="listbox">
+            <Show
+              when={visibleEntries().length > 0}
+              fallback={
+                <div class="command-palette-v2-state">
+                  {entries.loading ? language.t("common.loading") : language.t("palette.empty")}
+                </div>
+              }
+            >
+              <For each={groupedEntries()}>
+                {(group) => (
+                  <div class="command-palette-v2-group">
+                    <Show when={group.category}>
+                      <div class="command-palette-v2-group-title">{group.category}</div>
+                    </Show>
+                    <For each={group.entries}>
+                      {(item) => (
+                        <PaletteRow
+                          item={item}
+                          active={activeEntry()?.id === item.id}
+                          language={language}
+                          sessionOpen={
+                            item.server && item.sessionID
+                              ? openSessions().has(`${item.server}\0${item.sessionID}`)
+                              : false
+                          }
+                          onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
+                          onSelect={() => props.select(item)}
+                        />
+                      )}
+                    </For>
+                  </div>
+                )}
+              </For>
+            </Show>
+          </div>
+        </ScrollView>
+      </DialogBody>
+    </Dialog>
+  )
+}
+
+function PaletteRow(props: {
+  item: CommandPaletteEntry
+  active: boolean
+  language: ReturnType<typeof useLanguage>
+  sessionOpen: boolean
+  onActive: () => void
+  onSelect: () => void
+}) {
+  const session = () =>
+    props.item.server && props.item.directory && props.item.sessionID
+      ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
+      : undefined
+
+  return (
+    <button
+      type="button"
+      class="command-palette-v2-row group"
+      role="option"
+      aria-selected={props.active}
+      data-active={props.active ? "" : undefined}
+      onMouseMove={(event) => {
+        // Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
+        if (event.movementX === 0 && event.movementY === 0) return
+        props.onActive()
+      }}
+      onMouseDown={(event) => event.preventDefault()}
+      onClick={props.onSelect}
+    >
+      <Switch
+        fallback={
+          <div class="command-palette-v2-row-main">
+            <FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
+            <div class="command-palette-v2-file-path">
+              <span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
+              <span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
+            </div>
+          </div>
+        }
+      >
+        <Match when={props.item.type === "command"}>
+          <div class="command-palette-v2-row-main">
+            <div class="command-palette-v2-row-text">
+              <span class="command-palette-v2-title">{props.item.title}</span>
+              <Show when={props.item.description}>
+                <span class="command-palette-v2-description">{props.item.description}</span>
+              </Show>
+            </div>
+          </div>
+          <Show when={props.item.keybind}>
+            <KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
+          </Show>
+        </Match>
+        <Match when={props.item.type === "session"}>
+          <div class="command-palette-v2-row-main">
+            <div class="relative shrink-0">
+              <Show when={props.sessionOpen}>
+                <span
+                  aria-hidden="true"
+                  class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
+                  style={{ right: "calc(100% + 4px)" }}
+                />
+              </Show>
+              <Show when={session()}>
+                {(session) => (
+                  <SessionTabAvatar
+                    project={props.item.project}
+                    directory={session().directory}
+                    sessionId={session().sessionID}
+                    server={session().server}
+                  />
+                )}
+              </Show>
+            </div>
+            <div class="command-palette-v2-row-text">
+              <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
+                {props.item.title}
+              </span>
+              <Show when={props.item.description}>
+                <span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
+                  {props.item.description}
+                </span>
+              </Show>
+            </div>
+          </div>
+          <Show when={props.item.updated}>
+            <span class="command-palette-v2-meta">
+              {getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
+            </span>
+          </Show>
+        </Match>
+      </Switch>
+    </button>
+  )
+}

+ 73 - 0
packages/app/src/components/dialog-connect-provider.stories.tsx

@@ -0,0 +1,73 @@
+// @ts-nocheck
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
+import { mockProviderAuth } from "@/context/server-sync"
+import { onCleanup, onMount } from "solid-js"
+import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
+
+function ConnectProviderDialogStory() {
+  const dialog = useDialog()
+  const open = () => dialog.show(() => <DialogConnectProvider />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open connect provider dialog
+    </Button>
+  )
+}
+
+function ProviderConnectionDialogStory(props) {
+  onCleanup(mockProviderAuth(props.provider, props.methods))
+  const dialog = useDialog()
+  const controller = useProviderConnectController()
+  controller.select(props.provider)
+  const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open {props.provider} connection dialog
+    </Button>
+  )
+}
+
+function renderConnection(provider, methods) {
+  return () => (
+    <QueryClientProvider client={new QueryClient()}>
+      <ProviderConnectionDialogStory provider={provider} methods={methods} />
+    </QueryClientProvider>
+  )
+}
+
+export default {
+  title: "App/Dialogs/Connect Provider",
+  id: "app-dialog-connect-provider",
+}
+
+export const V2 = {
+  render: () => (
+    <QueryClientProvider client={new QueryClient()}>
+      <ConnectProviderDialogStory />
+    </QueryClientProvider>
+  ),
+}
+
+export const ApiKey = {
+  render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
+}
+
+export const OpenCodeZen = {
+  render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
+}
+
+export const LoginMethods = {
+  render: renderConnection("openai", [
+    { type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
+    { type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
+    { type: "api", label: "API key" },
+  ]),
+}

+ 612 - 90
packages/app/src/components/dialog-connect-provider.tsx

@@ -7,29 +7,399 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tag } from "@opencode-ai/ui/tag"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { showToast } from "@/utils/toast"
 import { showToast } from "@/utils/toast"
-import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
+import {
+  type Accessor,
+  type Component,
+  createEffect,
+  createMemo,
+  createResource,
+  createUniqueId,
+  For,
+  Match,
+  onCleanup,
+  onMount,
+  Show,
+  Switch,
+} from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
-import { useProviders } from "@/hooks/use-providers"
+import { useSettings } from "@/context/settings"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { CustomProviderForm } from "./dialog-custom-provider"
 
 
-export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
+const CUSTOM_ID = "_custom"
+
+export function useProviderConnectController(options: { onBack?: () => void } = {}) {
+  const [store, setStore] = createStore({ selected: undefined as string | undefined })
+  const reset = () => setStore("selected", undefined)
+
+  return {
+    selected: () => store.selected,
+    select: (provider?: string) => setStore("selected", provider),
+    back: options.onBack ?? reset,
+  }
+}
+
+export const DialogConnectProvider: Component<{
+  directory?: Accessor<string | undefined>
+  controller?: ReturnType<typeof useProviderConnectController>
+}> = (props) => {
+  const fallback = useProviderConnectController()
+  const controller = props.controller ?? fallback
+  const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
+  const reset = controller.back
+  const back = { current: reset }
+  let focusHost: HTMLDivElement | undefined
+  const holdFocus = () => focusHost?.focus({ preventScroll: true })
+  const select = (provider?: string) => {
+    back.current = reset
+    controller.select(provider)
+  }
+
+  function Content() {
+    return (
+      <Switch>
+        <Match when={controller.selected() === CUSTOM_ID}>
+          <CustomProviderForm autofocus={!newLayout()} />
+        </Match>
+        <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
+          {(provider) => (
+            <ProviderConnection
+              provider={provider()}
+              directory={props.directory}
+              onBack={reset}
+              setBack={(handler) => (back.current = handler)}
+            />
+          )}
+        </Match>
+        <Match when={true}>
+          <ProviderPicker
+            directory={props.directory}
+            onSelect={select}
+            onPrepare={newLayout() ? holdFocus : undefined}
+          />
+        </Match>
+      </Switch>
+    )
+  }
+
+  return (
+    <Show
+      when={newLayout()}
+      fallback={
+        <Dialog
+          class="h-full"
+          transition
+          title={
+            <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
+              <IconButton
+                tabIndex={-1}
+                icon="arrow-left"
+                variant="ghost"
+                onClick={() => back.current()}
+                aria-label={language.t("common.goBack")}
+              />
+            </Show>
+          }
+        >
+          <Content />
+        </Dialog>
+      }
+    >
+      <DialogV2
+        containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
+        class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
+      >
+        <DialogHeader closeLabel={language.t("common.close")}>
+          <Show
+            when={controller.selected()}
+            fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
+          >
+            <button
+              type="button"
+              class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+              onClick={() => back.current()}
+              aria-label={language.t("common.goBack")}
+            >
+              <Icon name="arrow-left" size="small" />
+            </button>
+          </Show>
+        </DialogHeader>
+        <DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
+          <div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
+            <Content />
+          </div>
+        </DialogBody>
+      </DialogV2>
+    </Show>
+  )
+}
+
+function ProviderPicker(props: {
+  directory?: Accessor<string | undefined>
+  onSelect: (provider: string) => void
+  onPrepare?: () => void
+}) {
+  const settings = useSettings()
+  if (settings.general.newLayoutDesigns())
+    return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
+  const providers = useProviders(props.directory)
+  const language = useLanguage()
+  const popularGroup = () => language.t("dialog.provider.group.popular")
+  const otherGroup = () => language.t("dialog.provider.group.other")
+  const customLabel = () => language.t("settings.providers.tag.custom")
+  const note = (id: string) => {
+    if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
+    if (id === "openai") return language.t("dialog.provider.openai.note")
+    if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
+    if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
+    return undefined
+  }
+
+  return (
+    <List
+      class="px-3"
+      search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
+      emptyMessage={language.t("dialog.provider.empty")}
+      activeIcon="plus-small"
+      key={(x) => x?.id}
+      items={() => {
+        language.locale()
+        return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
+      }}
+      filterKeys={["id", "name"]}
+      groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
+      sortBy={(a, b) => {
+        if (a.id === CUSTOM_ID) return -1
+        if (b.id === CUSTOM_ID) return 1
+        if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+          return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+        return a.name.localeCompare(b.name)
+      }}
+      sortGroupsBy={(a, b) => {
+        const popular = popularGroup()
+        if (a.category === popular && b.category !== popular) return -1
+        if (b.category === popular && a.category !== popular) return 1
+        return 0
+      }}
+      onSelect={(x) => {
+        if (!x) return
+        props.onSelect(x.id)
+      }}
+    >
+      {(i) => (
+        <div class="px-1.25 w-full flex items-center gap-x-3">
+          <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
+          <span>{i.name}</span>
+          <Show when={i.id === "opencode"}>
+            <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
+          </Show>
+          <Show when={i.id === CUSTOM_ID}>
+            <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+          </Show>
+          <Show when={i.id === "opencode"}>
+            <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
+          </Show>
+          <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
+          <Show when={i.id === "opencode-go"}>
+            <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
+          </Show>
+        </div>
+      )}
+    </List>
+  )
+}
+
+function ProviderPickerV2(props: {
+  directory?: Accessor<string | undefined>
+  onSelect: (provider: string) => void
+  onPrepare?: () => void
+}) {
+  const providers = useProviders(props.directory)
+  const language = useLanguage()
+  const serverSync = useServerSync()
+  const serverSDK = useServerSDK()
+  const [store, setStore] = createStore({
+    filter: "",
+    active: undefined as string | undefined,
+    connecting: undefined as string | undefined,
+  })
+  const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
+  const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
+  const all = createMemo(() => {
+    language.locale()
+    const query = store.filter.trim().toLowerCase()
+    const values = [custom(), ...providers.all().values()]
+    if (!query) return values
+    return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
+  })
+  const popular = createMemo(() =>
+    all()
+      .filter((provider) => featured.includes(provider.id))
+      .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
+  )
+  const other = createMemo(() =>
+    all()
+      .filter((provider) => !featured.includes(provider.id))
+      .sort((a, b) => {
+        if (a.id === CUSTOM_ID) return -1
+        if (b.id === CUSTOM_ID) return 1
+        return a.name.localeCompare(b.name)
+      }),
+  )
+  const rows = createMemo(() => [...popular(), ...other()])
+  let picker: HTMLDivElement | undefined
+  let search: HTMLInputElement | undefined
+
+  onMount(() => search?.focus({ preventScroll: true }))
+
+  const connect = (provider: string) => {
+    props.onPrepare?.()
+    if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
+      props.onSelect(provider)
+      return
+    }
+    if (store.connecting) return
+    setStore("connecting", provider)
+    void serverSDK()
+      .client.provider.auth()
+      .then((response) => {
+        serverSync().set("provider_auth", response.data ?? {})
+        props.onSelect(provider)
+      })
+      .catch(() => props.onSelect(provider))
+  }
+
+  const move = (event: KeyboardEvent, direction: number) => {
+    const items = rows()
+    if (items.length === 0) return
+    const index = items.findIndex((provider) => provider.id === store.active)
+    const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
+    setStore("active", items[next].id)
+    picker
+      ?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
+      ?.focus({ preventScroll: true })
+    event.preventDefault()
+  }
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "ArrowDown") return move(event, 1)
+    if (event.key === "ArrowUp") return move(event, -1)
+    if (event.key !== "Enter" || !store.active) return
+    connect(store.active)
+    event.preventDefault()
+  }
+
+  return (
+    <div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
+      <div class="shrink-0 px-1 pt-px">
+        <TextInputV2
+          ref={search}
+          type="search"
+          class="!w-full [font-family:var(--v2-font-family-sans)]"
+          leadingIcon={<Icon name="magnifying-glass" size="small" />}
+          placeholder={language.t("dialog.provider.search.placeholder")}
+          value={store.filter}
+          onInput={(event) => {
+            setStore({ filter: event.currentTarget.value, active: undefined })
+          }}
+        />
+      </div>
+      <div class="relative min-h-0 flex-1">
+        <div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
+          <For
+            each={[
+              { title: language.t("dialog.provider.group.popular"), items: popular },
+              { title: language.t("dialog.provider.group.other"), items: other },
+            ]}
+          >
+            {(group) => (
+              <Show when={group.items().length > 0}>
+                <section class="flex flex-col">
+                  <div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
+                    {group.title}
+                  </div>
+                  <For each={group.items()}>
+                    {(provider) => (
+                      <button
+                        type="button"
+                        data-provider-id={provider.id}
+                        class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                        classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
+                        onMouseEnter={() => setStore("active", provider.id)}
+                        disabled={store.connecting !== undefined}
+                        aria-busy={store.connecting === provider.id}
+                        onClick={() => connect(provider.id)}
+                      >
+                        <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
+                        <span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
+                        <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
+                          <span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
+                            {language.t(
+                              provider.id === "opencode"
+                                ? "dialog.provider.opencode.tagline"
+                                : "dialog.provider.opencodeGo.tagline",
+                            )}
+                          </span>
+                          <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                            {language.t("dialog.provider.tag.recommended")}
+                          </span>
+                        </Show>
+                        <Show when={provider.id === CUSTOM_ID}>
+                          <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                            {language.t("settings.providers.tag.custom")}
+                          </span>
+                        </Show>
+                        <Show when={store.connecting === provider.id}>
+                          <Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
+                        </Show>
+                      </button>
+                    )}
+                  </For>
+                </section>
+              </Show>
+            )}
+          </For>
+          <Show when={rows().length === 0}>
+            <div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
+              {language.t("dialog.provider.empty")}
+            </div>
+          </Show>
+        </div>
+        <div
+          class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
+          style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
+        />
+      </div>
+    </div>
+  )
+}
+
+function ProviderConnection(props: {
+  provider: string
+  directory?: Accessor<string | undefined>
+  onBack: () => void
+  setBack: (handler: () => void) => void
+}) {
   const dialog = useDialog()
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const serverSDK = useServerSDK()
   const language = useLanguage()
   const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
   const providers = useProviders(props.directory)
   const providers = useProviders(props.directory)
 
 
-  const all = () => {
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
-    })
-  }
-
   const alive = { value: true }
   const alive = { value: true }
   const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
   const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
 
 
@@ -62,11 +432,19 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   )
   )
   const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
   const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
   const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
   const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
+  const cachedMethods = serverSync().data.provider_auth[props.provider]
+  const directMethod =
+    cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
-    methodIndex: undefined as undefined | number,
+    methodIndex: directMethod as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     promptInputs: undefined as undefined | Record<string, string>,
     promptInputs: undefined as undefined | Record<string, string>,
-    state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
+    state: (directMethod === undefined ? "pending" : undefined) as
+      | undefined
+      | "pending"
+      | "complete"
+      | "error"
+      | "prompt",
     error: undefined as string | undefined,
     error: undefined as string | undefined,
   })
   })
 
 
@@ -134,6 +512,16 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
     return value.label ?? ""
     return value.label ?? ""
   }
   }
 
 
+  const methodDetails = (value?: { type?: string; label?: string }) => {
+    const label = methodLabel(value)
+    const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
+    const hint = suffix?.[1]
+    return {
+      label: suffix ? label.slice(0, -suffix[0].length) : label,
+      hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
+    }
+  }
+
   function formatError(value: unknown, fallback: string): string {
   function formatError(value: unknown, fallback: string): string {
     if (value && typeof value === "object" && "data" in value) {
     if (value && typeof value === "object" && "data" in value) {
       const data = (value as { data?: { message?: unknown } }).data
       const data = (value as { data?: { message?: unknown } }).data
@@ -364,22 +752,47 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
   }
 
 
   function goBack() {
   function goBack() {
-    if (methods().length === 1) {
-      all()
-      return
-    }
-    if (store.authorization) {
-      dispatch({ type: "method.reset" })
-      return
-    }
-    if (store.methodIndex !== undefined) {
+    if (methods().length > 1 && store.methodIndex !== undefined) {
       dispatch({ type: "method.reset" })
       dispatch({ type: "method.reset" })
       return
       return
     }
     }
-    all()
+    props.onBack()
   }
   }
 
 
+  props.setBack(goBack)
+
   function MethodSelection() {
   function MethodSelection() {
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-2">
+          <div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+            {language.t("provider.connect.selectMethod", { provider: provider().name })}
+          </div>
+          <div class="flex flex-col">
+            <For each={methods()}>
+              {(item, index) => {
+                const details = () => methodDetails(item)
+                return (
+                  <button
+                    type="button"
+                    class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+                    onClick={() => void selectMethod(index())}
+                  >
+                    <span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
+                      <span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
+                    </span>
+                    <span class="font-[530] text-v2-text-text-base">{details().label}</span>
+                    <Show when={details().hint}>
+                      {(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
+                    </Show>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </div>
+      )
+
     return (
     return (
       <>
       <>
         <div class="text-14-regular text-text-base">
         <div class="text-14-regular text-text-base">
@@ -413,11 +826,18 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
   }
 
 
   function ApiAuthView() {
   function ApiAuthView() {
+    let apiKey: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
     const [formStore, setFormStore] = createStore({
       value: "",
       value: "",
       error: undefined as string | undefined,
       error: undefined as string | undefined,
     })
     })
 
 
+    onMount(() => {
+      if (!newLayout()) return
+      apiKey?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
       e.preventDefault()
 
 
@@ -442,6 +862,58 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
       await complete()
       await complete()
     }
     }
 
 
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+          <Show
+            when={provider().id === "opencode"}
+            fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
+          >
+            <div class="flex flex-col gap-5">
+              <div>{language.t("provider.connect.opencodeZen.line1")}</div>
+              <div>{language.t("provider.connect.opencodeZen.line2")}</div>
+              <div>
+                {language.t("provider.connect.opencodeZen.visit.prefix")}
+                <Link
+                  href="https://opencode.ai/zen"
+                  class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
+                >
+                  {language.t("provider.connect.opencodeZen.visit.link")}
+                </Link>
+                {language.t("provider.connect.opencodeZen.visit.suffix")}
+              </div>
+            </div>
+          </Show>
+          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
+            <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
+              {language.t("provider.connect.apiKey.label", { provider: provider().name })}
+              <TextInputV2
+                ref={apiKey}
+                class="!w-full"
+                name="apiKey"
+                placeholder={language.t("provider.connect.apiKey.placeholder")}
+                value={formStore.value}
+                invalid={formStore.error !== undefined}
+                aria-describedby={formStore.error ? errorID : undefined}
+                autocomplete="off"
+                spellcheck={false}
+                onInput={(event) => setFormStore("value", event.currentTarget.value)}
+              />
+            </label>
+            <Show when={formStore.error}>
+              {(error) => (
+                <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
+                  {error()}
+                </div>
+              )}
+            </Show>
+            <ButtonV2 type="submit" variant="contrast">
+              {language.t("common.continue")}
+            </ButtonV2>
+          </form>
+        </div>
+      )
+
     return (
     return (
       <div class="flex flex-col gap-6">
       <div class="flex flex-col gap-6">
         <Switch>
         <Switch>
@@ -466,7 +938,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
         </Switch>
         </Switch>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={apiKey}
             type="text"
             type="text"
             label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
             label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
             placeholder={language.t("provider.connect.apiKey.placeholder")}
             placeholder={language.t("provider.connect.apiKey.placeholder")}
@@ -485,11 +958,18 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
   }
 
 
   function OAuthCodeView() {
   function OAuthCodeView() {
+    let codeInput: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
     const [formStore, setFormStore] = createStore({
       value: "",
       value: "",
       error: undefined as string | undefined,
       error: undefined as string | undefined,
     })
     })
 
 
+    onMount(() => {
+      if (!newLayout()) return
+      codeInput?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
       e.preventDefault()
 
 
@@ -518,6 +998,46 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
       setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
       setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
     }
     }
 
 
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+          <div>
+            {language.t("provider.connect.oauth.code.visit.prefix")}
+            <Link href={store.authorization!.url} class="text-v2-text-text-base">
+              {language.t("provider.connect.oauth.code.visit.link")}
+            </Link>
+            {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
+          </div>
+          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
+            <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
+              {language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
+              <TextInputV2
+                ref={codeInput}
+                class="!w-full"
+                name="code"
+                placeholder={language.t("provider.connect.oauth.code.placeholder")}
+                value={formStore.value}
+                invalid={formStore.error !== undefined}
+                aria-describedby={formStore.error ? errorID : undefined}
+                autocomplete="off"
+                spellcheck={false}
+                onInput={(event) => setFormStore("value", event.currentTarget.value)}
+              />
+            </label>
+            <Show when={formStore.error}>
+              {(error) => (
+                <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
+                  {error()}
+                </div>
+              )}
+            </Show>
+            <ButtonV2 type="submit" variant="contrast">
+              {language.t("common.continue")}
+            </ButtonV2>
+          </form>
+        </div>
+      )
+
     return (
     return (
       <div class="flex flex-col gap-6">
       <div class="flex flex-col gap-6">
         <div class="text-14-regular text-text-base">
         <div class="text-14-regular text-text-base">
@@ -527,7 +1047,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
         </div>
         </div>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={codeInput}
             type="text"
             type="text"
             label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
             label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
             placeholder={language.t("provider.connect.oauth.code.placeholder")}
             placeholder={language.t("provider.connect.oauth.code.placeholder")}
@@ -599,79 +1120,80 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
   }
 
 
   return (
   return (
-    <Dialog
-      title={
-        <IconButton
-          tabIndex={-1}
-          icon="arrow-left"
-          variant="ghost"
-          onClick={goBack}
-          aria-label={language.t("common.goBack")}
+    <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
+      <div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
+        <ProviderIcon
+          id={props.provider}
+          class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
         />
         />
-      }
-    >
-      <div class="flex flex-col gap-6 px-2.5 pb-3">
-        <div class="px-2.5 flex gap-4 items-center">
-          <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
-          <div class="text-16-medium text-text-strong">
-            <Switch>
-              <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
-                {language.t("provider.connect.title.anthropicProMax")}
-              </Match>
-              <Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
-            </Switch>
-          </div>
+        <div
+          class={
+            newLayout()
+              ? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
+              : "text-16-medium text-text-strong"
+          }
+        >
+          <Switch>
+            <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
+              {language.t("provider.connect.title.anthropicProMax")}
+            </Match>
+            <Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
+          </Switch>
         </div>
         </div>
-        <div class="px-2.5 pb-10 flex flex-col gap-6">
-          <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
-            <Switch>
-              <Match when={loading()}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-2">
-                    <Spinner />
-                    <span>{language.t("provider.connect.status.inProgress")}</span>
-                  </div>
+      </div>
+      <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
+        <div
+          onKeyDown={handleKey}
+          tabIndex={newLayout() ? undefined : 0}
+          autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
+        >
+          <Switch>
+            <Match when={loading()}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-2">
+                  <Spinner />
+                  <span>{language.t("provider.connect.status.inProgress")}</span>
                 </div>
                 </div>
-              </Match>
-              <Match when={store.methodIndex === undefined}>
-                <MethodSelection />
-              </Match>
-              <Match when={store.state === "pending"}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-2">
-                    <Spinner />
-                    <span>{language.t("provider.connect.status.inProgress")}</span>
-                  </div>
+              </div>
+            </Match>
+            <Match when={store.methodIndex === undefined}>
+              <MethodSelection />
+            </Match>
+            <Match when={store.state === "pending"}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-2">
+                  <Spinner />
+                  <span>{language.t("provider.connect.status.inProgress")}</span>
                 </div>
                 </div>
-              </Match>
-              <Match when={store.state === "prompt"}>
-                <AuthPromptsView />
-              </Match>
-              <Match when={store.state === "error"}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-2">
-                    <Icon name="circle-ban-sign" class="text-icon-critical-base" />
-                    <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
-                  </div>
+              </div>
+            </Match>
+            <Match when={store.state === "prompt"}>
+              <AuthPromptsView />
+            </Match>
+            <Match when={store.state === "error"}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-2">
+                  <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+                  <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
                 </div>
                 </div>
-              </Match>
-              <Match when={method()?.type === "api"}>
-                <ApiAuthView />
-              </Match>
-              <Match when={method()?.type === "oauth"}>
-                <Switch>
-                  <Match when={store.authorization?.method === "code"}>
-                    <OAuthCodeView />
-                  </Match>
-                  <Match when={store.authorization?.method === "auto"}>
-                    <OAuthAutoView />
-                  </Match>
-                </Switch>
-              </Match>
-            </Switch>
-          </div>
+              </div>
+            </Match>
+            <Match when={method()?.type === "api"}>
+              <ApiAuthView />
+            </Match>
+            <Match when={method()?.type === "oauth"}>
+              <Switch>
+                <Match when={store.authorization?.method === "code"}>
+                  <OAuthCodeView />
+                </Match>
+                <Match when={store.authorization?.method === "auto"}>
+                  <OAuthAutoView />
+                </Match>
+              </Switch>
+            </Match>
+          </Switch>
         </div>
         </div>
       </div>
       </div>
-    </Dialog>
+    </div>
   )
   )
 }
 }

+ 165 - 166
packages/app/src/components/dialog-custom-provider.tsx

@@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { useMutation } from "@tanstack/solid-query"
 import { useMutation } from "@tanstack/solid-query"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@/utils/toast"
 import { showToast } from "@/utils/toast"
-import { type Accessor, batch, For } from "solid-js"
+import { batch, For } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
 import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
-import { DialogSelectProvider } from "./dialog-select-provider"
 
 
 type Props = {
 type Props = {
-  back?: "providers" | "close"
-  directory?: Accessor<string | undefined>
+  onBack: () => void
 }
 }
 
 
 export function DialogCustomProvider(props: Props) {
 export function DialogCustomProvider(props: Props) {
+  const language = useLanguage()
+
+  return (
+    <Dialog
+      class="h-full"
+      title={
+        <IconButton
+          tabIndex={-1}
+          icon="arrow-left"
+          variant="ghost"
+          onClick={props.onBack}
+          aria-label={language.t("common.goBack")}
+        />
+      }
+      transition
+    >
+      <CustomProviderForm />
+    </Dialog>
+  )
+}
+
+export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
   const dialog = useDialog()
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const serverSDK = useServerSDK()
@@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
     err: {},
     err: {},
   })
   })
 
 
-  const goBack = () => {
-    if (props.back === "close") {
-      dialog.close()
-      return
-    }
-    dialog.show(() => <DialogSelectProvider directory={props.directory} />)
-  }
-
   const addModel = () => {
   const addModel = () => {
     setForm(
     setForm(
       "models",
       "models",
@@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) {
   }
   }
 
 
   return (
   return (
-    <Dialog
-      title={
-        <IconButton
-          tabIndex={-1}
-          icon="arrow-left"
-          variant="ghost"
-          onClick={goBack}
-          aria-label={language.t("common.goBack")}
-        />
-      }
-      transition
-    >
-      <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
-        <div class="px-2.5 flex gap-4 items-center">
-          <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
-          <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
-        </div>
+    <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
+      <div class="px-2.5 flex gap-4 items-center">
+        <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
+        <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
+      </div>
 
 
-        <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
-          <p class="text-14-regular text-text-base">
-            {language.t("provider.custom.description.prefix")}
-            <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
-              {language.t("provider.custom.description.link")}
-            </Link>
-            {language.t("provider.custom.description.suffix")}
-          </p>
+      <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
+        <p class="text-14-regular text-text-base">
+          {language.t("provider.custom.description.prefix")}
+          <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
+            {language.t("provider.custom.description.link")}
+          </Link>
+          {language.t("provider.custom.description.suffix")}
+        </p>
 
 
-          <div class="flex flex-col gap-4">
-            <TextField
-              autofocus
-              label={language.t("provider.custom.field.providerID.label")}
-              placeholder={language.t("provider.custom.field.providerID.placeholder")}
-              description={language.t("provider.custom.field.providerID.description")}
-              value={form.providerID}
-              onChange={(v) => setField("providerID", v)}
-              validationState={form.err.providerID ? "invalid" : undefined}
-              error={form.err.providerID}
-            />
-            <TextField
-              label={language.t("provider.custom.field.name.label")}
-              placeholder={language.t("provider.custom.field.name.placeholder")}
-              value={form.name}
-              onChange={(v) => setField("name", v)}
-              validationState={form.err.name ? "invalid" : undefined}
-              error={form.err.name}
-            />
-            <TextField
-              label={language.t("provider.custom.field.baseURL.label")}
-              placeholder={language.t("provider.custom.field.baseURL.placeholder")}
-              value={form.baseURL}
-              onChange={(v) => setField("baseURL", v)}
-              validationState={form.err.baseURL ? "invalid" : undefined}
-              error={form.err.baseURL}
-            />
-            <TextField
-              label={language.t("provider.custom.field.apiKey.label")}
-              placeholder={language.t("provider.custom.field.apiKey.placeholder")}
-              description={language.t("provider.custom.field.apiKey.description")}
-              value={form.apiKey}
-              onChange={(v) => setField("apiKey", v)}
-            />
-          </div>
+        <div class="flex flex-col gap-4">
+          <TextField
+            autofocus={props.autofocus ?? true}
+            label={language.t("provider.custom.field.providerID.label")}
+            placeholder={language.t("provider.custom.field.providerID.placeholder")}
+            description={language.t("provider.custom.field.providerID.description")}
+            value={form.providerID}
+            onChange={(v) => setField("providerID", v)}
+            validationState={form.err.providerID ? "invalid" : undefined}
+            error={form.err.providerID}
+          />
+          <TextField
+            label={language.t("provider.custom.field.name.label")}
+            placeholder={language.t("provider.custom.field.name.placeholder")}
+            value={form.name}
+            onChange={(v) => setField("name", v)}
+            validationState={form.err.name ? "invalid" : undefined}
+            error={form.err.name}
+          />
+          <TextField
+            label={language.t("provider.custom.field.baseURL.label")}
+            placeholder={language.t("provider.custom.field.baseURL.placeholder")}
+            value={form.baseURL}
+            onChange={(v) => setField("baseURL", v)}
+            validationState={form.err.baseURL ? "invalid" : undefined}
+            error={form.err.baseURL}
+          />
+          <TextField
+            label={language.t("provider.custom.field.apiKey.label")}
+            placeholder={language.t("provider.custom.field.apiKey.placeholder")}
+            description={language.t("provider.custom.field.apiKey.description")}
+            value={form.apiKey}
+            onChange={(v) => setField("apiKey", v)}
+          />
+        </div>
 
 
-          <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
-            <For each={form.models}>
-              {(m, i) => (
-                <div class="flex gap-2 items-start" data-row={m.row}>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.models.id.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.models.id.placeholder")}
-                      value={m.id}
-                      onChange={(v) => setModel(i(), "id", v)}
-                      validationState={m.err.id ? "invalid" : undefined}
-                      error={m.err.id}
-                    />
-                  </div>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.models.name.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.models.name.placeholder")}
-                      value={m.name}
-                      onChange={(v) => setModel(i(), "name", v)}
-                      validationState={m.err.name ? "invalid" : undefined}
-                      error={m.err.name}
-                    />
-                  </div>
-                  <IconButton
-                    type="button"
-                    icon="trash"
-                    variant="ghost"
-                    class="mt-1.5"
-                    onClick={() => removeModel(i())}
-                    disabled={form.models.length <= 1}
-                    aria-label={language.t("provider.custom.models.remove")}
+        <div class="flex flex-col gap-3">
+          <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
+          <For each={form.models}>
+            {(m, i) => (
+              <div class="flex gap-2 items-start" data-row={m.row}>
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.models.id.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.models.id.placeholder")}
+                    value={m.id}
+                    onChange={(v) => setModel(i(), "id", v)}
+                    validationState={m.err.id ? "invalid" : undefined}
+                    error={m.err.id}
                   />
                   />
                 </div>
                 </div>
-              )}
-            </For>
-            <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
-              {language.t("provider.custom.models.add")}
-            </Button>
-          </div>
-
-          <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
-            <For each={form.headers}>
-              {(h, i) => (
-                <div class="flex gap-2 items-start" data-row={h.row}>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.headers.key.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.headers.key.placeholder")}
-                      value={h.key}
-                      onChange={(v) => setHeader(i(), "key", v)}
-                      validationState={h.err.key ? "invalid" : undefined}
-                      error={h.err.key}
-                    />
-                  </div>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.headers.value.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.headers.value.placeholder")}
-                      value={h.value}
-                      onChange={(v) => setHeader(i(), "value", v)}
-                      validationState={h.err.value ? "invalid" : undefined}
-                      error={h.err.value}
-                    />
-                  </div>
-                  <IconButton
-                    type="button"
-                    icon="trash"
-                    variant="ghost"
-                    class="mt-1.5"
-                    onClick={() => removeHeader(i())}
-                    disabled={form.headers.length <= 1}
-                    aria-label={language.t("provider.custom.headers.remove")}
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.models.name.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.models.name.placeholder")}
+                    value={m.name}
+                    onChange={(v) => setModel(i(), "name", v)}
+                    validationState={m.err.name ? "invalid" : undefined}
+                    error={m.err.name}
                   />
                   />
                 </div>
                 </div>
-              )}
-            </For>
-            <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
-              {language.t("provider.custom.headers.add")}
-            </Button>
-          </div>
+                <IconButton
+                  type="button"
+                  icon="trash"
+                  variant="ghost"
+                  class="mt-1.5"
+                  onClick={() => removeModel(i())}
+                  disabled={form.models.length <= 1}
+                  aria-label={language.t("provider.custom.models.remove")}
+                />
+              </div>
+            )}
+          </For>
+          <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
+            {language.t("provider.custom.models.add")}
+          </Button>
+        </div>
 
 
-          <Button
-            class="w-auto self-start"
-            type="submit"
-            size="large"
-            variant="primary"
-            disabled={saveMutation.isPending}
-          >
-            {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
+        <div class="flex flex-col gap-3">
+          <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
+          <For each={form.headers}>
+            {(h, i) => (
+              <div class="flex gap-2 items-start" data-row={h.row}>
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.headers.key.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.headers.key.placeholder")}
+                    value={h.key}
+                    onChange={(v) => setHeader(i(), "key", v)}
+                    validationState={h.err.key ? "invalid" : undefined}
+                    error={h.err.key}
+                  />
+                </div>
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.headers.value.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.headers.value.placeholder")}
+                    value={h.value}
+                    onChange={(v) => setHeader(i(), "value", v)}
+                    validationState={h.err.value ? "invalid" : undefined}
+                    error={h.err.value}
+                  />
+                </div>
+                <IconButton
+                  type="button"
+                  icon="trash"
+                  variant="ghost"
+                  class="mt-1.5"
+                  onClick={() => removeHeader(i())}
+                  disabled={form.headers.length <= 1}
+                  aria-label={language.t("provider.custom.headers.remove")}
+                />
+              </div>
+            )}
+          </For>
+          <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
+            {language.t("provider.custom.headers.add")}
           </Button>
           </Button>
-        </form>
-      </div>
-    </Dialog>
+        </div>
+
+        <Button
+          class="w-auto self-start"
+          type="submit"
+          size="large"
+          variant="primary"
+          disabled={saveMutation.isPending}
+        >
+          {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
+        </Button>
+      </form>
+    </div>
   )
   )
 }
 }

+ 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>

+ 3 - 3
packages/app/src/components/dialog-manage-models.tsx

@@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
 import { popularProviders } from "@/hooks/use-providers"
 import { popularProviders } from "@/hooks/use-providers"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogConnectProvider } from "./dialog-connect-provider"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { SettingsListV2 } from "./settings-v2/parts/list"
 import { SettingsListV2 } from "./settings-v2/parts/list"
 import { SettingsRowV2 } from "./settings-v2/parts/row"
 import { SettingsRowV2 } from "./settings-v2/parts/row"
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
   const directory = () => decode64(local.slug())
   const directory = () => decode64(local.slug())
 
 
   const handleConnectProvider = () => {
   const handleConnectProvider = () => {
-    dialog.show(() => <DialogSelectProvider directory={directory} />)
+    void dialog.show(() => <DialogConnectProvider directory={directory} />)
   }
   }
   const providerRank = (id: string) => popularProviders.indexOf(id)
   const providerRank = (id: string) => popularProviders.indexOf(id)
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
   const directory = () => decode64(local.slug())
   const directory = () => decode64(local.slug())
 
 
   const handleConnectProvider = () => {
   const handleConnectProvider = () => {
-    dialog.show(() => <DialogSelectProvider directory={directory} />)
+    void dialog.show(() => <DialogConnectProvider directory={directory} />)
   }
   }
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
   const providerVisible = (providerID: string) =>
   const providerVisible = (providerID: string) =>

+ 65 - 368
packages/app/src/components/dialog-select-file.tsx

@@ -1,329 +1,81 @@
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Keybind } from "@opencode-ai/ui/keybind"
 import { Keybind } from "@opencode-ai/ui/keybind"
 import { List } from "@opencode-ai/ui/list"
 import { List } from "@opencode-ai/ui/list"
-import { base64Encode } from "@opencode-ai/core/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import { useNavigate } from "@solidjs/router"
-import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
-import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
-import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
-import { useServerSync } from "@/context/server-sync"
-import { useLayout } from "@/context/layout"
-import { useFile } from "@/context/file"
+import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
+import { formatKeybind } from "@/context/command"
+import { useServerSDK } from "@/context/server-sdk"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { useSettings } from "@/context/settings"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { createSessionTabs } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { getRelativeTime } from "@/utils/time"
 import { getRelativeTime } from "@/utils/time"
+import {
+  createCommandPaletteFileEntry,
+  createCommandPaletteFileOpener,
+  createCommandPaletteModel,
+  uniqueCommandPaletteEntries,
+  type CommandPaletteEntry,
+} from "./command-palette"
+import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
 
 
 const DialogSelectFileV2 = lazy(() =>
 const DialogSelectFileV2 = lazy(() =>
   import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
   import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
 )
 )
-
-type EntryType = "command" | "file" | "session"
-
-type Entry = {
-  id: string
-  type: EntryType
-  title: string
-  description?: string
-  keybind?: string
-  category: string
-  option?: CommandOption
-  path?: string
-  directory?: string
-  sessionID?: string
-  archived?: number
-  updated?: number
-}
-
 type DialogSelectFileMode = "all" | "files"
 type DialogSelectFileMode = "all" | "files"
 
 
-const ENTRY_LIMIT = 5
-const COMMON_COMMAND_IDS = [
-  "session.new",
-  "workspace.new",
-  "session.previous",
-  "session.next",
-  "terminal.toggle",
-  "review.toggle",
-] as const
-
-const uniqueEntries = (items: Entry[]) => {
-  const seen = new Set<string>()
-  const out: Entry[] = []
-  for (const item of items) {
-    if (seen.has(item.id)) continue
-    seen.add(item.id)
-    out.push(item)
-  }
-  return out
-}
-
-const createCommandEntry = (option: CommandOption, category: string): Entry => ({
-  id: "command:" + option.id,
-  type: "command",
-  title: option.title,
-  description: option.description,
-  keybind: option.keybind,
-  category,
-  option,
-})
-
-const createFileEntry = (path: string, category: string): Entry => ({
-  id: "file:" + path,
-  type: "file",
-  title: path,
-  category,
-  path,
-})
-
-const createSessionEntry = (
-  input: {
-    directory: string
-    id: string
-    title: string
-    description: string
-    archived?: number
-    updated?: number
-  },
-  category: string,
-): Entry => ({
-  id: `session:${input.directory}:${input.id}`,
-  type: "session",
-  title: input.title,
-  description: input.description,
-  category,
-  directory: input.directory,
-  sessionID: input.id,
-  archived: input.archived,
-  updated: input.updated,
-})
-
-function createCommandEntries(props: {
-  filesOnly: () => boolean
-  command: ReturnType<typeof useCommand>
-  language: ReturnType<typeof useLanguage>
-}) {
-  const allowed = createMemo(() => {
-    if (props.filesOnly()) return []
-    return props.command.options.filter(
-      (option) =>
-        !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
-    )
-  })
-
-  const list = createMemo(() => {
-    const category = props.language.t("palette.group.commands")
-    return allowed().map((option) => createCommandEntry(option, category))
-  })
-
-  const picks = createMemo(() => {
-    const all = allowed()
-    const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
-    const picked = all.filter((option) => order.has(option.id))
-    const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
-    const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
-    const category = props.language.t("palette.group.commands")
-    return sorted.map((option) => createCommandEntry(option, category))
-  })
-
-  return { allowed, list, picks }
-}
-
-function createFileEntries(props: {
-  file: ReturnType<typeof useFile>
-  tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
-  language: ReturnType<typeof useLanguage>
-}) {
-  const tabState = createSessionTabs({
-    tabs: props.tabs,
-    pathFromTab: props.file.pathFromTab,
-    normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
-  })
-  const recent = createMemo(() => {
-    const all = tabState.openedTabs()
-    const active = tabState.activeFileTab()
-    const order = active ? [active, ...all.filter((item) => item !== active)] : all
-    const seen = new Set<string>()
-    const category = props.language.t("palette.group.files")
-    const items: Entry[] = []
-
-    for (const item of order) {
-      const path = props.file.pathFromTab(item)
-      if (!path) continue
-      if (seen.has(path)) continue
-      seen.add(path)
-      items.push(createFileEntry(path, category))
-    }
-
-    return items.slice(0, ENTRY_LIMIT)
-  })
-
-  const root = createMemo(() => {
-    const category = props.language.t("palette.group.files")
-    const nodes = props.file.tree.children("")
-    const paths = nodes
-      .filter((node) => node.type === "file")
-      .map((node) => node.path)
-      .sort((a, b) => a.localeCompare(b))
-    return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
-  })
-
-  return { recent, root }
-}
+export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
+  const platform = usePlatform()
+  const settings = useSettings()
+  const filesOnly = () => props.mode === "files"
 
 
-function createSessionEntries(props: {
-  workspaces: () => string[]
-  label: (directory: string) => string
-  serverSDK: ServerSDK
-  language: ReturnType<typeof useLanguage>
-}) {
-  const state: {
-    token: number
-    inflight: Promise<Entry[]> | undefined
-    cached: Entry[] | undefined
-  } = {
-    token: 0,
-    inflight: undefined,
-    cached: undefined,
+  if (!filesOnly() && settings.general.newLayoutDesigns()) {
+    return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
   }
   }
 
 
-  const sessions = (text: string) => {
-    const query = text.trim()
-    if (!query) {
-      state.token += 1
-      state.inflight = undefined
-      state.cached = undefined
-      return [] as Entry[]
-    }
-
-    if (state.cached) return state.cached
-    if (state.inflight) return state.inflight
-
-    const current = state.token
-    const dirs = props.workspaces()
-    if (dirs.length === 0) return [] as Entry[]
-
-    state.inflight = Promise.all(
-      dirs.map((directory) => {
-        const description = props.label(directory)
-        return props.serverSDK.client.session
-          .list({ directory, roots: true })
-          .then((x) =>
-            (x.data ?? [])
-              .filter((s) => !!s?.id)
-              .map((s) => ({
-                id: s.id,
-                title: s.title ?? props.language.t("command.session.new"),
-                description,
-                directory,
-                archived: s.time?.archived,
-                updated: s.time?.updated,
-              })),
-          )
-          .catch(
-            () =>
-              [] as {
-                id: string
-                title: string
-                description: string
-                directory: string
-                archived?: number
-                updated?: number
-              }[],
-          )
-      }),
-    )
-      .then((results) => {
-        if (state.token !== current) return [] as Entry[]
-        const seen = new Set<string>()
-        const category = props.language.t("command.category.session")
-        const next = results
-          .flat()
-          .filter((item) => {
-            const key = `${item.directory}:${item.id}`
-            if (seen.has(key)) return false
-            seen.add(key)
-            return true
-          })
-          .map((item) => createSessionEntry(item, category))
-        state.cached = next
-        return next
-      })
-      .catch(() => [] as Entry[])
-      .finally(() => {
-        state.inflight = undefined
-      })
-
-    return state.inflight
+  if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
+    return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
   }
   }
 
 
-  return { sessions }
+  return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
 }
 }
 
 
-export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
-  const command = useCommand()
+function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
   const language = useLanguage()
   const language = useLanguage()
-  const platform = usePlatform()
-  const settings = useSettings()
-  const layout = useLayout()
-  const file = useFile()
-  const dialog = useDialog()
-  const navigate = useNavigate()
   const serverSDK = useServerSDK()
   const serverSDK = useServerSDK()
-  const serverSync = useServerSync()
-  const { params, tabs, view } = useSessionLayout()
-  const filesOnly = () => props.mode === "files"
-  const state = { cleanup: undefined as (() => void) | void, committed: false }
-  const [grouped, setGrouped] = createSignal(false)
-  const commandEntries = createCommandEntries({ filesOnly, command, language })
-  const fileEntries = createFileEntries({ file, tabs, language })
-
+  const { params } = useSessionLayout()
   const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
   const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
-  const project = createMemo(() => {
-    const directory = projectDirectory()
-    if (!directory) return
-    return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
-  })
-  const workspaces = createMemo(() => {
-    const directory = projectDirectory()
-    const current = project()
-    if (!current) return directory ? [directory] : []
+  const openFile = createCommandPaletteFileOpener(props.onOpenFile)
 
 
-    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
-    if (directory && !dirs.includes(directory)) return [...dirs, directory]
-    return dirs
-  })
-  const homedir = createMemo(() => serverSync().data.path.home)
-  const label = (directory: string) => {
-    const current = project()
-    const kind =
-      current && directory === current.worktree
-        ? language.t("workspace.type.local")
-        : language.t("workspace.type.sandbox")
-    const [store] = serverSync().child(directory, { bootstrap: false })
-    const home = homedir()
-    const path = home ? directory.replace(home, "~") : directory
-    const name = store.vcs?.branch ?? getFilename(directory)
-    return `${kind} : ${name || path}`
-  }
+  return (
+    <DialogSelectFileV2
+      server={serverSDK().server}
+      mode="file"
+      start={projectDirectory()}
+      title={language.t("session.header.searchFiles")}
+      onSelect={(result) => {
+        if (typeof result !== "string") return
+        openFile(result)
+      }}
+    />
+  )
+}
 
 
-  const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
+function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
+  const palette = createCommandPaletteModel(props)
+  const [grouped, setGrouped] = createSignal(false)
 
 
   const items = async (text: string) => {
   const items = async (text: string) => {
     const query = text.trim()
     const query = text.trim()
     setGrouped(query.length > 0)
     setGrouped(query.length > 0)
 
 
-    if (!query && filesOnly()) {
-      const loaded = file.tree.state("")?.loaded
-      const pending = loaded ? Promise.resolve() : file.tree.list("")
-      const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
+    if (!query && props.filesOnly()) {
+      const loaded = palette.file.tree.state("")?.loaded
+      const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
+      const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
 
 
       if (loaded || next.length > 0) {
       if (loaded || next.length > 0) {
         void pending
         void pending
@@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       }
       }
 
 
       await pending
       await pending
-      return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
-    }
-
-    if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
-
-    if (filesOnly()) {
-      const files = await file.searchFiles(query)
-      const category = language.t("palette.group.files")
-      return files.map((path) => createFileEntry(path, category))
+      return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
     }
     }
 
 
-    const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
-    const category = language.t("palette.group.files")
-    const entries = files.map((path) => createFileEntry(path, category))
-    return [...commandEntries.list(), ...nextSessions, ...entries]
-  }
-
-  const handleMove = (item: Entry | undefined) => {
-    state.cleanup?.()
-    if (!item) return
-    if (item.type !== "command") return
-    state.cleanup = item.option?.onHighlight?.()
-  }
-
-  const open = (path: string) => {
-    const value = file.tab(path)
-    void tabs().open(value)
-    void file.load(path)
-    if (!view().reviewPanel.opened()) view().reviewPanel.open()
-    layout.fileTree.setTab("all")
-    props.onOpenFile?.(path)
-    tabs().setActive(value)
-  }
-
-  const handleSelect = (item: Entry | undefined) => {
-    if (!item) return
-    state.committed = true
-    state.cleanup = undefined
-    dialog.close()
+    if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
 
 
-    if (item.type === "command") {
-      item.option?.onSelect?.("palette")
-      return
+    if (props.filesOnly()) {
+      const files = await palette.file.searchFiles(query)
+      const category = palette.language.t("palette.group.files")
+      return files.map((path) => createCommandPaletteFileEntry(path, category))
     }
     }
 
 
-    if (item.type === "session") {
-      if (!item.directory || !item.sessionID) return
-      navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
-      return
-    }
-
-    if (!item.path) return
-    open(item.path)
-  }
-
-  onCleanup(() => {
-    if (state.committed) return
-    state.cleanup?.()
-  })
-
-  if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
-    return (
-      <DialogSelectFileV2
-        server={serverSDK().server}
-        mode="file"
-        start={projectDirectory()}
-        title={language.t("session.header.searchFiles")}
-        onSelect={(result) => {
-          if (typeof result !== "string") return
-          open(result)
-        }}
-      />
-    )
+    const [files, nextSessions] = await Promise.all([
+      palette.file.searchFiles(query),
+      Promise.resolve(palette.sessions(query)),
+    ])
+    const category = palette.language.t("palette.group.files")
+    const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
+    return [...palette.commandEntries(), ...nextSessions, ...entries]
   }
   }
 
 
   return (
   return (
@@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       <List
       <List
         class="px-3"
         class="px-3"
         search={{
         search={{
-          placeholder: filesOnly()
-            ? language.t("session.header.searchFiles")
-            : language.t("palette.search.placeholder"),
+          placeholder: props.filesOnly()
+            ? palette.language.t("session.header.searchFiles")
+            : palette.language.t("palette.search.placeholder"),
           autofocus: true,
           autofocus: true,
           hideIcon: true,
           hideIcon: true,
         }}
         }}
-        emptyMessage={language.t("palette.empty")}
-        loadingMessage={language.t("common.loading")}
+        emptyMessage={palette.language.t("palette.empty")}
+        loadingMessage={palette.language.t("common.loading")}
         items={items}
         items={items}
         key={(item) => item.id}
         key={(item) => item.id}
         filterKeys={["title", "description", "category"]}
         filterKeys={["title", "description", "category"]}
         skipFilter={(item) => item.type === "file"}
         skipFilter={(item) => item.type === "file"}
         groupBy={grouped() ? (item) => item.category : () => ""}
         groupBy={grouped() ? (item) => item.category : () => ""}
-        onMove={handleMove}
-        onSelect={handleSelect}
+        onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
+        onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
       >
       >
         {(item) => (
         {(item) => (
           <Switch
           <Switch
@@ -452,7 +149,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                   </Show>
                   </Show>
                 </div>
                 </div>
                 <Show when={item.keybind}>
                 <Show when={item.keybind}>
-                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
+                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
                 </Show>
                 </Show>
               </div>
               </div>
             </Match>
             </Match>
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                 </div>
                 </div>
                 <Show when={item.updated}>
                 <Show when={item.updated}>
                   <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
                   <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
-                    {getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
+                    {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
                   </span>
                   </span>
                 </Show>
                 </Show>
               </div>
               </div>

+ 55 - 0
packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx

@@ -0,0 +1,55 @@
+// @ts-nocheck
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createSignal, onMount } from "solid-js"
+import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
+
+const names = [
+  "MiMo V2.5 Free",
+  "Nemotron 3 Ultra Free",
+  "Deepseek V4 Flash Free",
+  "North Mini Code Free",
+  "Hy3 Free",
+  "Big Pickle",
+]
+
+function SelectModelWithoutProviders() {
+  const dialog = useDialog()
+  const models = names.map((name, index) => ({
+    id: name.toLowerCase().replaceAll(" ", "-"),
+    name,
+    provider: { id: "opencode", name: "OpenCode" },
+    cost: { input: 0, output: 0 },
+    limit: { context: 128_000 },
+    capabilities: {
+      reasoning: index !== 5,
+      input: { text: true, image: false, audio: false, video: false, pdf: false },
+    },
+  }))
+  const [current, setCurrent] = createSignal(models[2])
+  const model = {
+    list: () => models,
+    current,
+    set(value) {
+      setCurrent(models.find((item) => item.id === value?.modelID))
+    },
+  }
+  const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open select model dialog
+    </Button>
+  )
+}
+
+export default {
+  title: "App/Dialogs/Select Model",
+  id: "app-dialog-select-model",
+}
+
+export const WithoutProviders = {
+  render: () => <SelectModelWithoutProviders />,
+}

+ 175 - 0
packages/app/src/components/dialog-select-model-unpaid-v2.tsx

@@ -0,0 +1,175 @@
+import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
+import { Icon } from "@opencode-ai/ui/v2/icon"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { Tag } from "@opencode-ai/ui/v2/badge-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useTheme } from "@opencode-ai/ui/theme"
+import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useProviders } from "@/hooks/use-providers"
+import { decode64 } from "@/utils/base64"
+import { useLanguage } from "@/context/language"
+import { ModelTooltip } from "./model-tooltip"
+
+type ModelState = ReturnType<typeof useLocal>["model"]
+const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
+const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
+
+export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
+  const local = useLocal()
+  const model = props.model ?? local.model
+  const dialog = useDialog()
+  const theme = useTheme()
+  const directory = () => decode64(local.slug())
+  const providers = useProviders(directory)
+  const language = useLanguage()
+  const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
+  const currentKey = createMemo(() => {
+    const c = model.current()
+    return c ? `${c.provider.id}:${c.id}` : undefined
+  })
+  const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
+    item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
+  const freeModels = createMemo(() => model.list().filter(isFree))
+
+  const openProviders = (provider?: string) => {
+    void import("./dialog-connect-provider").then((x) => {
+      const controller = x.useProviderConnectController()
+      controller.select(provider)
+      void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
+    })
+  }
+
+  const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
+    model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
+    dialog.close()
+  }
+
+  // Focus starts on the dialog's close button, outside the list, so listen at the
+  // document level while the dialog is mounted instead of on the list container.
+  let listEl: HTMLDivElement | undefined
+  onMount(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
+      if (!listEl) return
+      const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
+      if (buttons.length === 0) return
+      const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
+      const next =
+        index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
+      buttons[(next + buttons.length) % buttons.length]?.focus()
+      e.preventDefault()
+    }
+    document.addEventListener("keydown", handleKeyDown)
+    onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
+  })
+
+  return (
+    <DialogV2
+      fit
+      containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
+      class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
+    >
+      <DialogHeader closeLabel={language.t("common.close")}>
+        <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
+      </DialogHeader>
+      <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
+        <div ref={listEl} class="flex min-h-0 flex-col">
+          <div class="flex w-full flex-col items-start pb-3">
+            <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
+              <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
+                {language.t("dialog.model.unpaid.freeModels.title")}
+              </div>
+            </div>
+            <For each={freeModels()}>
+              {(item) => (
+                <TooltipV2
+                  class="w-full"
+                  placement="right-start"
+                  gutter={6}
+                  openDelay={0}
+                  contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
+                  value={
+                    <ModelTooltip
+                      model={{ ...item, name: displayModelName(item.name) }}
+                      latest={item.latest}
+                      free={isFree(item)}
+                      v2
+                    />
+                  }
+                >
+                  <button
+                    type="button"
+                    class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                    onClick={() => selectModel(item)}
+                  >
+                    <span class="min-w-0 truncate">{displayModelName(item.name)}</span>
+                    <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
+                    <Show when={item.latest}>
+                      <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
+                    </Show>
+                    <Show when={currentKey() === modelKey(item)}>
+                      <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
+                    </Show>
+                  </button>
+                </TooltipV2>
+              )}
+            </For>
+          </div>
+
+          <div class="flex w-full flex-col">
+            <div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
+              <div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
+                <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
+                  {language.t("dialog.model.unpaid.addMore.title")}
+                </div>
+              </div>
+              <div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
+                <For
+                  each={[...providers.popular()]
+                    .filter((provider) => featuredProviders.includes(provider.id))
+                    .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
+                >
+                  {(provider) => (
+                    <button
+                      type="button"
+                      class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
+                      classList={{
+                        "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
+                          theme.mode() !== "dark",
+                        "border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
+                      }}
+                      onClick={() => openProviders(provider.id)}
+                    >
+                      <ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
+                      <span class="flex min-w-0 flex-col">
+                        <span class="truncate">{provider.name}</span>
+                        <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
+                          <span class="truncate font-[440] text-v2-text-text-muted">
+                            {language.t(
+                              provider.id === "opencode"
+                                ? "dialog.provider.opencode.tagline"
+                                : "dialog.provider.opencodeGo.tagline",
+                            )}
+                          </span>
+                        </Show>
+                      </span>
+                    </button>
+                  )}
+                </For>
+                <button
+                  type="button"
+                  class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                  onClick={() => openProviders()}
+                >
+                  {language.t("dialog.model.unpaid.viewMoreProviders")}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </DialogBody>
+    </DialogV2>
+  )
+}

+ 6 - 7
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
   const providers = useProviders(directory)
   const providers = useProviders(directory)
   const language = useLanguage()
   const language = useLanguage()
 
 
-  const connect = (provider: string) => {
+  const openProviders = (provider?: string) => {
     void import("./dialog-connect-provider").then((x) => {
     void import("./dialog-connect-provider").then((x) => {
-      dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
+      const controller = x.useProviderConnectController()
+      controller.select(provider)
+      void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
     })
     })
   }
   }
 
 
-  const all = () => {
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
-    })
-  }
+  const connect = (provider: string) => openProviders(provider)
+  const all = () => openProviders()
 
 
   let listRef: ListRef | undefined
   let listRef: ListRef | undefined
   const handleKeyDown = (e: KeyboardEvent) => {
   const handleKeyDown = (e: KeyboardEvent) => {

+ 40 - 23
packages/app/src/components/dialog-select-model.tsx

@@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
 import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
 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 { ModelTooltip } from "./model-tooltip"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
@@ -92,6 +93,7 @@ const ModelList: Component<{
           class="w-full"
           class="w-full"
           placement="right-start"
           placement="right-start"
           gutter={12}
           gutter={12}
+          openDelay={0}
           value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
           value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
         >
         >
           {node}
           {node}
@@ -155,8 +157,8 @@ export function ModelSelectorPopover(props: {
 
 
   const handleConnectProvider = () => {
   const handleConnectProvider = () => {
     close("provider")
     close("provider")
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
+    void import("./dialog-connect-provider").then((x) => {
+      void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
     })
     })
   }
   }
   const language = useLanguage()
   const language = useLanguage()
@@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
                       <MenuV2.RadioGroup value={current()}>
                       <MenuV2.RadioGroup value={current()}>
                         <For each={group.items}>
                         <For each={group.items}>
                           {(item) => (
                           {(item) => (
-                            <MenuV2.RadioItem
-                              value={modelKey(item)}
-                              data-option-key={modelKey(item)}
-                              data-selected-model={current() === modelKey(item) ? true : undefined}
-                              class="scroll-my-6"
-                              classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
-                              onMouseEnter={() => {
-                                setStore("active", modelKey(item))
-                                setTimeout(() => searchRef?.focus())
-                              }}
-                              onSelect={() => selectModel(item)}
+                            <TooltipV2
+                              class="w-full"
+                              placement="right-start"
+                              gutter={6}
+                              openDelay={0}
+                              value={
+                                <ModelTooltip
+                                  model={item}
+                                  latest={item.latest}
+                                  free={isFree(item.provider.id, item.cost)}
+                                  v2
+                                />
+                              }
                             >
                             >
-                              <span class="min-w-0 truncate">{item.name}</span>
-                              <Show when={isFree(item.provider.id, item.cost)}>
-                                <TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
-                              </Show>
-                              <Show when={item.latest}>
-                                <TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
-                              </Show>
-                            </MenuV2.RadioItem>
+                              <MenuV2.RadioItem
+                                value={modelKey(item)}
+                                data-option-key={modelKey(item)}
+                                data-selected-model={current() === modelKey(item) ? true : undefined}
+                                class="scroll-my-6 w-full"
+                                classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
+                                onMouseEnter={() => {
+                                  setStore("active", modelKey(item))
+                                  setTimeout(() => searchRef?.focus())
+                                }}
+                                onSelect={() => selectModel(item)}
+                              >
+                                <span class="min-w-0 truncate leading-5">{item.name}</span>
+                                <Show when={isFree(item.provider.id, item.cost)}>
+                                  <TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
+                                </Show>
+                                <Show when={item.latest}>
+                                  <TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
+                                </Show>
+                              </MenuV2.RadioItem>
+                            </TooltipV2>
                           )}
                           )}
                         </For>
                         </For>
                       </MenuV2.RadioGroup>
                       </MenuV2.RadioGroup>
@@ -503,8 +520,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
   const directory = () => decode64(local.slug())
   const directory = () => decode64(local.slug())
 
 
   const provider = () => {
   const provider = () => {
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
+    void import("./dialog-connect-provider").then((x) => {
+      void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
     })
     })
   }
   }
 
 

+ 0 - 87
packages/app/src/components/dialog-select-provider.tsx

@@ -1,87 +0,0 @@
-import { type Accessor, Component, Show } from "solid-js"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { Tag } from "@opencode-ai/ui/tag"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-import { useLanguage } from "@/context/language"
-import { DialogCustomProvider } from "./dialog-custom-provider"
-
-const CUSTOM_ID = "_custom"
-
-export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
-  const dialog = useDialog()
-  const providers = useProviders(props.directory)
-  const language = useLanguage()
-
-  const popularGroup = () => language.t("dialog.provider.group.popular")
-  const otherGroup = () => language.t("dialog.provider.group.other")
-  const customLabel = () => language.t("settings.providers.tag.custom")
-  const note = (id: string) => {
-    if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
-    if (id === "openai") return language.t("dialog.provider.openai.note")
-    if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
-    if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
-  }
-
-  return (
-    <Dialog title={language.t("command.provider.connect")} transition>
-      <List
-        class="px-3"
-        search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
-        emptyMessage={language.t("dialog.provider.empty")}
-        activeIcon="plus-small"
-        key={(x) => x?.id}
-        items={() => {
-          language.locale()
-          return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
-        }}
-        filterKeys={["id", "name"]}
-        groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
-        sortBy={(a, b) => {
-          if (a.id === CUSTOM_ID) return -1
-          if (b.id === CUSTOM_ID) return 1
-          if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
-            return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
-          return a.name.localeCompare(b.name)
-        }}
-        sortGroupsBy={(a, b) => {
-          const popular = popularGroup()
-          if (a.category === popular && b.category !== popular) return -1
-          if (b.category === popular && a.category !== popular) return 1
-          return 0
-        }}
-        onSelect={(x) => {
-          if (!x) return
-          if (x.id === CUSTOM_ID) {
-            dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
-            return
-          }
-          dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
-        }}
-      >
-        {(i) => (
-          <div class="px-1.25 w-full flex items-center gap-x-3">
-            <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
-            <span>{i.name}</span>
-            <Show when={i.id === "opencode"}>
-              <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
-            </Show>
-            <Show when={i.id === CUSTOM_ID}>
-              <Tag>{language.t("settings.providers.tag.custom")}</Tag>
-            </Show>
-            <Show when={i.id === "opencode"}>
-              <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
-            </Show>
-            <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
-            <Show when={i.id === "opencode-go"}>
-              <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
-            </Show>
-          </div>
-        )}
-      </List>
-    </Dialog>
-  )
-}

+ 17 - 4
packages/app/src/components/dialog-settings.tsx

@@ -1,22 +1,35 @@
-import { Component } from "solid-js"
+import { Component, createSignal, startTransition } from "solid-js"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { SettingsGeneral } from "./settings-general"
 import { SettingsGeneral } from "./settings-general"
 import { SettingsKeybinds } from "./settings-keybinds"
 import { SettingsKeybinds } from "./settings-keybinds"
 import { SettingsProviders } from "./settings-providers"
 import { SettingsProviders } from "./settings-providers"
 import { SettingsModels } from "./settings-models"
 import { SettingsModels } from "./settings-models"
 import { SettingsServers } from "./settings-servers"
 import { SettingsServers } from "./settings-servers"
 
 
-export const DialogSettings: Component = () => {
+export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
   const language = useLanguage()
   const language = useLanguage()
   const platform = usePlatform()
   const platform = usePlatform()
+  const dialog = useDialog()
+  const [tab, setTab] = createSignal(props.defaultValue ?? "general")
+
+  const showProviders = () => {
+    void dialog.show(() => <DialogSettings defaultValue="providers" />)
+  }
 
 
   return (
   return (
     <Dialog size="x-large" transition>
     <Dialog size="x-large" transition>
-      <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
+      <Tabs
+        orientation="vertical"
+        variant="settings"
+        value={tab()}
+        onChange={(value) => void startTransition(() => setTab(value))}
+        class="h-full settings-dialog"
+      >
         <Tabs.List>
         <Tabs.List>
           <div class="flex flex-col justify-between h-full w-full gap-4">
           <div class="flex flex-col justify-between h-full w-full gap-4">
             <div class="flex flex-col gap-3 w-full pt-3">
             <div class="flex flex-col gap-3 w-full pt-3">
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
           <SettingsServers />
           <SettingsServers />
         </Tabs.Content>
         </Tabs.Content>
         <Tabs.Content value="providers" class="no-scrollbar">
         <Tabs.Content value="providers" class="no-scrollbar">
-          <SettingsProviders />
+          <SettingsProviders onBack={showProviders} />
         </Tabs.Content>
         </Tabs.Content>
         <Tabs.Content value="models" class="no-scrollbar">
         <Tabs.Content value="models" class="no-scrollbar">
           <SettingsModels />
           <SettingsModels />

+ 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
+    },
+  }
+}

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

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

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

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

+ 169 - 291
packages/app/src/components/file-tree-v2.tsx

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

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

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

+ 108 - 43
packages/app/src/components/help-button.tsx

@@ -1,54 +1,119 @@
-import { Icon } from "@opencode-ai/ui/v2/icon"
-import { Popover } from "@opencode-ai/ui/popover"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { createSignal, Show } from "solid-js"
 import { createSignal, Show } from "solid-js"
-import { createStore } from "solid-js/store"
+import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
+import { usePlatform } from "@/context/platform"
+import { useSettings } from "@/context/settings"
+import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
+import homeImage from "@/assets/help/home.png"
+import tabsImage from "@/assets/help/tabs.png"
 
 
-export function HelpButton() {
-  if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
+// TODO: wire to changelog / seen-state when available
+const showPopover = () => true
 
 
-  const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
-  const [shown, setShown] = createSignal(false)
+// can remove this after the tabs rollout has been out for a while
+export function TabsInfoPopup() {
+  const settings = useSettings()
+  const platform = usePlatform()
+  const [drawerOpen, setDrawerOpen] = createSignal(false)
 
 
   return (
   return (
-    <Show when={!state.dismissed}>
-      <div class="fixed bottom-4 right-4 z-50 hidden md:block">
-        <Popover
-          open={shown()}
-          onOpenChange={setShown}
-          triggerAs="button"
-          triggerProps={{
-            type: "button",
-            "aria-label": "Help",
-            class:
-              "size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
-          }}
-          trigger={<span aria-hidden="true">?</span>}
-          class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
-          gutter={8}
-          placement="top-end"
+    <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
+      <Show when={settings.general.shouldDisplayTabsToast()}>
+        <div
+          class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
+          aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
         >
         >
-          <Show when={shown()}>
-            <div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
-              <button
-                type="button"
-                aria-label="Close"
-                class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
-                onClick={() => {
-                  setShown(false)
-                  setState("dismissed", true)
-                }}
-              >
-                <Icon name="xmark-small" />
-              </button>
-              <span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
-              <p class="text-12-regular text-text-weak">
-                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
-                dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
+          <button
+            type="button"
+            aria-label="Dismiss Tabs information"
+            class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
+            onClick={settings.general.dismissTabsToast}
+          >
+            <svg
+              width="16"
+              height="16"
+              viewBox="0 0 16 16"
+              fill="none"
+              xmlns="http://www.w3.org/2000/svg"
+              aria-hidden="true"
+            >
+              <path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
+            </svg>
+          </button>
+          <button
+            type="button"
+            class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
+            onClick={() => {
+              settings.general.dismissTabsToast()
+              setDrawerOpen(true)
+            }}
+          >
+            <video
+              src={introducingTabsVideo}
+              class="absolute inset-0 h-full w-full object-cover"
+              loop
+              muted
+              autoplay
+              playsinline
+              aria-hidden="true"
+              onContextMenu={(event) => event.preventDefault()}
+            />
+            <div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
+              <p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
+                Introducing Tabs
+              </p>
+              <p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
+                Organize your work and active sessions with tabs
               </p>
               </p>
             </div>
             </div>
+          </button>
+        </div>
+      </Show>
+      <DrawerContent>
+        <div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
+          <p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
+            July 14
+          </p>
+          <Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
+            <DrawerClose
+              as={IconButtonV2}
+              type="button"
+              size="small"
+              variant="ghost-muted"
+              aria-label="Close"
+              icon={<IconV2 name="xmark-small" />}
+            />
           </Show>
           </Show>
-        </Popover>
-      </div>
-    </Show>
+        </div>
+        <div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
+          <p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
+            Introducing Tabs
+          </p>
+          <div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
+            <p>OpenCode Desktop is now built around tabs.</p>
+            <img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
+            <p>
+              Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
+              you're starting something new, and close it when you're done.
+            </p>
+            <p>
+              Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
+              memorable if you plan to keep them around.
+            </p>
+            <p>
+              You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
+            </p>
+            <img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
+            <p>When you reopen the app, your tabs are still open.</p>
+            <p>
+              The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
+              the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
+              will become permanent in a few weeks.
+            </p>
+          </div>
+        </div>
+      </DrawerContent>
+    </Drawer>
   )
   )
 }
 }

+ 35 - 2
packages/app/src/components/model-tooltip.tsx

@@ -1,4 +1,4 @@
-import { Show, type Component } from "solid-js"
+import { Show, type Component, type JSX } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 
 
 type InputKey = "text" | "image" | "audio" | "video" | "pdf"
 type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,7 +23,18 @@ type ModelInfo = {
   }
   }
 }
 }
 
 
-export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
+function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
+  return (
+    <div class="flex min-w-0 items-center gap-4">
+      <span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
+      <span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
+    </div>
+  )
+}
+
+export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
+  props,
+) => {
   const language = useLanguage()
   const language = useLanguage()
   const sourceName = (model: ModelInfo) => {
   const sourceName = (model: ModelInfo) => {
     const value = `${model.id} ${model.name}`.toLowerCase()
     const value = `${model.id} ${model.name}`.toLowerCase()
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
     const suffix = tags.length ? ` (${tags.join(", ")})` : ""
     const suffix = tags.length ? ` (${tags.join(", ")})` : ""
     return `${sourceName(props.model)} ${props.model.name}${suffix}`
     return `${sourceName(props.model)} ${props.model.name}${suffix}`
   }
   }
+  const name = () => {
+    const tags: Array<string> = []
+    if (props.latest) tags.push(language.t("model.tag.latest"))
+    if (props.free) tags.push(language.t("model.tag.free"))
+    const suffix = tags.length ? ` (${tags.join(", ")})` : ""
+    return `${props.model.name}${suffix}`
+  }
   const inputs = () => {
   const inputs = () => {
     if (props.model.capabilities) {
     if (props.model.capabilities) {
       const input = props.model.capabilities.input
       const input = props.model.capabilities.input
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
       : language.t("model.tooltip.reasoning.none")
       : language.t("model.tooltip.reasoning.none")
   }
   }
   const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
   const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
+  const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
+
+  if (props.v2) {
+    return (
+      <div class="flex w-[180px] flex-col gap-2">
+        <ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
+        <ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
+        <Show when={inputs()}>
+          {(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
+        </Show>
+        <ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
+        <ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
+      </div>
+    )
+  }
 
 
   return (
   return (
     <div class="flex flex-col gap-1 py-1">
     <div class="flex flex-col gap-1 py-1">

+ 591 - 0
packages/app/src/components/prompt-input-v2.tsx

@@ -0,0 +1,591 @@
+import { ImagePreview } from "@opencode-ai/ui/image-preview"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon } from "@opencode-ai/ui/v2/icon"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
+import { createEffect, createMemo, on, Show } from "solid-js"
+import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
+import type { PromptInputProps } from "@/components/prompt-input/contracts"
+import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
+import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
+import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
+import { createPromptSubmit } from "@/components/prompt-input/submit"
+import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
+import { useComments } from "@/context/comments"
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { usePermission } from "@/context/permission"
+import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
+import { usePlatform } from "@/context/platform"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { createSessionTabs } from "@/pages/session/helpers"
+import { showToast } from "@/utils/toast"
+import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
+import {
+  createPromptInputV2Controller,
+  createPromptInputV2State,
+  type PromptInputV2Interaction,
+} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
+
+export type PromptInputV2ComposerProps = {
+  class?: string
+  controller: PromptInputV2ComposerController
+  borderUnderlay?: boolean
+  edit?: PromptInputProps["edit"]
+  onEditLoaded?: PromptInputProps["onEditLoaded"]
+}
+
+export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
+export type PromptInputV2ComposerController = PromptInputV2Interaction & {
+  readonly model: PromptInputProps["controls"]["model"]
+}
+
+export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
+  const dialog = useDialog()
+  const command = useCommand()
+  const language = useLanguage()
+
+  useCommands(props)
+  useEditHandler(props)
+
+  return (
+    <div class="flex flex-col gap-3">
+      <PromptInputV2
+        controller={props.controller}
+        borderUnderlay={props.borderUnderlay}
+        class={props.class}
+        attachKeybind={command.keybindParts("file.attach")}
+        attachShortcut={command.keybind("file.attach")}
+        modelControl={
+          <PromptInputV2ModelControl
+            loading={props.controller.model.loading}
+            paid={props.controller.model.paid}
+            title={language.t("command.model.choose")}
+            keybind={command.keybindParts("model.choose")}
+            model={props.controller.model.selection}
+            providerID={props.controller.model.selection.current()?.provider?.id}
+            modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
+            onClose={props.controller.restoreFocus}
+            onUnpaidClick={() =>
+              dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
+            }
+          />
+        }
+      />
+    </div>
+  )
+}
+
+const useEditHandler = (props: PromptInputV2ComposerProps) => {
+  const prompt = usePrompt()
+
+  createEffect(
+    on(
+      () => props.edit?.id,
+      (id) => {
+        const edit = props.edit
+        if (!id || !edit) return
+        prompt.context.items().forEach((item) => prompt.context.remove(item.key))
+        edit.context.forEach((item) =>
+          prompt.context.add({
+            type: item.type,
+            path: item.path,
+            selection: item.selection,
+            comment: item.comment,
+            commentID: item.commentID,
+            commentOrigin: item.commentOrigin,
+            preview: item.preview,
+          }),
+        )
+        props.controller.dispatch({ type: "mode.normal" })
+        props.controller.resetHistory()
+        prompt.set(edit.prompt, promptLength(edit.prompt))
+        props.controller.restoreFocus()
+        props.onEditLoaded?.()
+      },
+      { defer: true },
+    ),
+  )
+}
+
+const useCommands = (props: PromptInputV2ComposerProps) => {
+  const command = useCommand()
+  const language = useLanguage()
+
+  command.register("prompt-input", () => [
+    {
+      id: "file.attach",
+      title: language.t("prompt.action.attachFile"),
+      category: language.t("command.category.file"),
+      keybind: "mod+u",
+      disabled: props.controller.state.mode !== "normal",
+      onSelect: () => props.controller.attach(),
+    },
+    {
+      id: "prompt.mode.shell",
+      title: language.t("command.prompt.mode.shell"),
+      category: language.t("command.category.session"),
+      keybind: "mod+shift+x",
+      disabled: props.controller.state.mode === "shell",
+      onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
+    },
+    {
+      id: "prompt.mode.normal",
+      title: language.t("command.prompt.mode.normal"),
+      category: language.t("command.category.session"),
+      keybind: "mod+shift+e",
+      disabled: props.controller.state.mode === "normal",
+      onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
+    },
+  ])
+}
+
+export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
+  const sdk = useSDK()
+  const sync = useSync()
+  const files = useFile()
+  const layout = useLayout()
+  const comments = useComments()
+  const dialog = useDialog()
+  const command = useCommand()
+  const permission = usePermission()
+  const language = useLanguage()
+  const platform = usePlatform()
+  const prompt = props.state ?? usePrompt()
+  let editor: HTMLDivElement | undefined
+
+  const interaction = createPromptInputV2State()
+  const mode = () => interaction[0].mode
+  const history = props.history ?? createPersistedPromptInputHistory()
+  const tabs = () => props.controls.session.tabs
+  const activeFileTab = createSessionTabs({
+    tabs,
+    pathFromTab: files.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
+  }).activeFileTab
+  const recent = createMemo(() => {
+    const all = tabs().all()
+    const active = activeFileTab()
+    const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
+    return order.reduce<string[]>((result, tab) => {
+      const path = files.pathFromTab(tab)
+      if (!path || result.includes(path)) return result
+      return [...result, path]
+    }, [])
+  })
+  const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
+  const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
+  const attachments = createMemo(() =>
+    prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
+  )
+  const commentCount = createMemo(() => {
+    if (mode() === "shell") return 0
+    return prompt.context.items().filter((item) => !!item.comment?.trim()).length
+  })
+  const blank = createMemo(() => {
+    const text = prompt
+      .current()
+      .map((part) => ("content" in part ? part.content : ""))
+      .join("")
+    return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
+  })
+  const stopping = createMemo(() => working() && blank())
+  const placeholder = createMemo(() =>
+    promptPlaceholder({
+      mode: mode(),
+      commentCount: commentCount(),
+      example: mode() === "shell" ? "git status" : "",
+      suggest: false,
+      t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
+    }),
+  )
+  const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
+
+  const historyComments = () => {
+    const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
+    return prompt.context.items().flatMap((item) => {
+      const comment = item.comment?.trim()
+      if (!comment) return []
+      const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
+      const nextSelection =
+        selection ??
+        (item.selection
+          ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
+          : undefined)
+      if (!nextSelection) return []
+      return [
+        {
+          id: item.commentID ?? item.key,
+          path: item.path,
+          selection: { ...nextSelection },
+          comment,
+          time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
+          origin: item.commentOrigin,
+          preview: item.preview,
+        } satisfies PromptHistoryComment,
+      ]
+    })
+  }
+  const restoreHistoryComments = (items: PromptHistoryComment[]) => {
+    comments.replace(
+      items.map((item) => ({
+        id: item.id,
+        file: item.path,
+        selection: { ...item.selection },
+        comment: item.comment,
+        time: item.time,
+      })),
+    )
+    prompt.context.replaceComments(
+      items.map((item) => ({
+        type: "file",
+        path: item.path,
+        selection: selectionFromLines(item.selection),
+        comment: item.comment,
+        commentID: item.id,
+        commentOrigin: item.origin,
+        preview: item.preview,
+      })),
+    )
+  }
+
+  const accepting = createMemo(() => {
+    const id = props.controls.session.id
+    if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
+    return permission.isAutoAccepting(id, sdk().directory)
+  })
+  const submission = createPromptSubmit({
+    prompt,
+    info,
+    imageAttachments: attachments,
+    commentCount,
+    autoAccept: accepting,
+    mode,
+    working,
+    editor: () => editor,
+    queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
+    promptLength,
+    addToHistory: (value, mode) => controller.addHistory(value, mode),
+    resetHistoryNavigation: () => controller.resetHistory(),
+    setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
+    setPopover: (popover) => {
+      if (!popover) controller.dispatch({ type: "popover.close" })
+    },
+    newSessionWorktree: () => props.newSessionWorktree,
+    onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+    shouldQueue: props.shouldQueue,
+    onQueue: props.onQueue,
+    onAbort: props.onAbort,
+    onSubmit: props.onSubmit,
+    model: props.controls.model.selection,
+  })
+
+  const referenceDescription = (reference: ReferenceInfo) =>
+    reference.source.type === "git" ? reference.source.repository : reference.source.path
+  const references = createMemo(() =>
+    sync()
+      .data.reference.filter((reference) => !reference.hidden)
+      .map((reference) => ({
+        id: `reference:${reference.name}`,
+        kind: "reference" as const,
+        label: `@${reference.name}`,
+        path: reference.path,
+        description: reference.description ?? referenceDescription(reference),
+        mention: {
+          type: "file" as const,
+          path: reference.path,
+          content: `@${reference.name}`,
+          start: 0,
+          end: 0,
+          mime: "application/x-directory",
+          filename: reference.name,
+        },
+      })),
+  )
+  const resources = createMemo(() =>
+    Object.values(sync().data.mcp_resource).map((resource) => ({
+      id: `resource:${resource.client}:${resource.uri}`,
+      kind: "resource" as const,
+      label: `@${resource.name}`,
+      path: resource.uri,
+      description: resource.description,
+      mention: {
+        type: "file" as const,
+        path: resource.uri,
+        content: `@${resource.name}`,
+        start: 0,
+        end: 0,
+        mime: resource.mimeType ?? "text/plain",
+        filename: resource.name,
+        url: resource.uri,
+        source: {
+          type: "resource" as const,
+          text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
+          clientName: resource.client,
+          uri: resource.uri,
+        },
+      },
+      resource,
+    })),
+  )
+  const context = createMemo<PromptInputV2Suggestion[]>(() => [
+    ...references(),
+    ...props.controls.agents.available
+      .filter((agent) => !agent.hidden && agent.mode !== "primary")
+      .map((agent) => ({
+        id: `agent:${agent.name}`,
+        kind: "agent" as const,
+        label: `@${agent.name}`,
+        mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
+      })),
+    ...resources(),
+    ...recent().map((path) => ({
+      id: `file:${path}`,
+      kind: "file" as const,
+      label: path,
+      path,
+      recent: true,
+      mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
+    })),
+  ])
+  const slashCommands = createMemo(() => [
+    ...sync().data.command.map((item) => ({
+      id: `custom.${item.name}`,
+      trigger: item.name,
+      title: item.name,
+      description: item.description,
+      type: "custom" as const,
+    })),
+    ...command.options
+      .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
+      .map((item) => ({
+        id: item.id,
+        trigger: item.slash!,
+        title: item.title,
+        description: item.description,
+        type: "builtin" as const,
+      })),
+  ])
+  const commands = createMemo<PromptInputV2Suggestion[]>(() =>
+    slashCommands().map((item) => ({
+      id: item.id,
+      kind: "command",
+      label: `/${item.trigger}`,
+      trigger: item.trigger,
+      title: item.title,
+      description: item.description,
+      keybind: command.keybindParts(item.id),
+    })),
+  )
+  const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
+  const controller = createPromptInputV2Controller({
+    store: () => prompt.capture().store,
+    state: interaction,
+    identity: () => prompt.capture(),
+    history: {
+      entries: (mode) =>
+        history.entries(mode).map((value) => {
+          const entry = normalizePromptHistoryEntry(value)
+          return { prompt: entry.prompt, metadata: entry.comments }
+        }),
+      add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
+      capture: historyComments,
+      restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
+    },
+    commands,
+    context,
+    searchContextFiles: async (query) =>
+      (await files.searchFilesAndDirectories(query)).map((path) => ({
+        id: `file:${path}`,
+        kind: "file",
+        label: path,
+        path,
+        mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
+      })),
+    onContextRemove(item) {
+      if (item?.commentID) comments.remove(item.path, item.commentID)
+    },
+    openAttachment: (attachment) =>
+      dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
+    openContext(key) {
+      const item = controller.contextItem(key)
+      if (item) openComment(item, props, sync, layout, files, comments)
+    },
+    onEditor(element) {
+      editor = element as HTMLDivElement
+      props.ref?.(editor)
+    },
+    onSuggestionSelect(item) {
+      if (item.kind !== "command") return
+      const selected = slashCommands().find((entry) => entry.id === item.id)
+      if (!selected || selected.type === "custom") return
+      return () => command.trigger(selected.id, "slash")
+    },
+    attachments: {
+      picker: platform.openAttachmentPickerDialog,
+      directory: () => sdk().directory,
+      isDialogActive: () => !!dialog.active,
+      warn: () =>
+        showToast({
+          title: language.t("prompt.toast.pasteUnsupported.title"),
+          description: language.t("prompt.toast.pasteUnsupported.description"),
+        }),
+      onError: (error) =>
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: error instanceof Error ? error.message : String(error),
+        }),
+      readClipboardImage: platform.readClipboardImage,
+      getPathForFile: platform.getPathForFile,
+    },
+    view: {
+      placeholder: designPlaceholder,
+      agent:
+        props.controls.agents.visible && props.controls.agents.options.length > 0
+          ? {
+              options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
+              current: () => props.controls.agents.current,
+              onSelect: props.controls.agents.select,
+              keybind: () => command.keybindParts("agent.cycle"),
+            }
+          : undefined,
+      variant: {
+        options: () => variants().map((value) => ({ id: value, label: value })),
+        current: () => props.controls.model.selection.variant.current() ?? "default",
+        onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
+        keybind: () => command.keybindParts("model.variant.cycle"),
+      },
+      submit: {
+        stopping,
+        working,
+        onSubmit: () => void submission.handleSubmit(new Event("submit")),
+        onStop: () => void submission.abort(),
+      },
+    },
+  })
+  Object.defineProperty(controller, "model", { get: () => props.controls.model })
+  return controller as PromptInputV2ComposerController
+}
+
+function PromptInputV2ModelControl(props: {
+  loading: boolean
+  paid: boolean
+  title: string
+  keybind: string[]
+  model: PromptInputV2ComposerController["model"]["selection"]
+  providerID?: string
+  modelName: string
+  onClose: () => void
+  onUnpaidClick: () => void
+}) {
+  const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
+  const content = () => (
+    <>
+      <Show when={props.providerID}>
+        {(providerID) => (
+          <ProviderIcon
+            id={providerID()}
+            class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+            style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+          />
+        )}
+      </Show>
+      <span class="truncate leading-4">{props.modelName}</span>
+      <span class="-ml-0.5 -mr-1 flex shrink-0">
+        <Icon name="chevron-down" />
+      </span>
+    </>
+  )
+  return (
+    <Show when={!props.loading}>
+      <TooltipV2
+        placement="top"
+        gutter={4}
+        value={
+          <>
+            {props.title}
+            <KeybindV2 keys={props.keybind} variant="neutral" />
+          </>
+        }
+      >
+        <Show
+          when={props.paid}
+          fallback={
+            <ButtonV2
+              data-action="prompt-model"
+              variant="ghost-muted"
+              size="normal"
+              class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
+              classList={{ "animate-in fade-in": shouldAnimate() }}
+              style={{ height: "28px" }}
+              onClick={props.onUnpaidClick}
+            >
+              {content()}
+            </ButtonV2>
+          }
+        >
+          <ModelSelectorPopoverV2
+            model={props.model}
+            triggerAs={ButtonV2}
+            triggerProps={{
+              variant: "ghost-muted",
+              size: "normal",
+              style: { height: "28px" },
+              class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
+              classList: { "animate-in fade-in": shouldAnimate() },
+              "data-action": "prompt-model",
+            }}
+            onClose={props.onClose}
+          >
+            {content()}
+          </ModelSelectorPopoverV2>
+        </Show>
+      </TooltipV2>
+    </Show>
+  )
+}
+
+function openComment(
+  item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
+  props: PromptInputV2ControllerProps,
+  sync: ReturnType<typeof useSync>,
+  layout: ReturnType<typeof useLayout>,
+  files: ReturnType<typeof useFile>,
+  comments: ReturnType<typeof useComments>,
+) {
+  if (!item.commentID) return
+  const focus = { file: item.path, id: item.commentID }
+  comments.setActive(focus)
+  const queueFocus = (attempts = 6) => {
+    requestAnimationFrame(() => {
+      comments.setFocus({ ...focus })
+      if (attempts <= 0) return
+      requestAnimationFrame(() => {
+        const current = comments.focus()
+        if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
+      })
+    })
+  }
+  const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
+  const review =
+    item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
+  if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
+  if (review) {
+    layout.fileTree.setTab("changes")
+    props.controls.session.tabs.setActive("review")
+    queueFocus()
+    return
+  }
+  layout.fileTree.setTab("all")
+  const tab = files.tab(item.path)
+  void props.controls.session.tabs.open(tab)
+  props.controls.session.tabs.setActive(tab)
+  void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
+}

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

@@ -30,8 +30,16 @@ function PromptInputExample() {
     activeTab: undefined as string | undefined,
     activeTab: undefined as string | undefined,
     reviewOpen: false,
     reviewOpen: false,
   })
   })
+  const storyModel = {
+    id: "claude-3-7-sonnet",
+    name: "Claude 3.7 Sonnet",
+    provider: { id: "anthropic", name: "Anthropic" },
+  }
   const model = {
   const model = {
-    current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+    current: () => storyModel,
+    list: () => [storyModel],
+    visible: () => true,
+    set: () => {},
     variant: {
     variant: {
       list: () => ["fast", "thinking"],
       list: () => ["fast", "thinking"],
       current: () => controls.variant,
       current: () => controls.variant,
@@ -67,7 +75,6 @@ function PromptInputExample() {
         open: () => setControls("reviewOpen", true),
         open: () => setControls("reviewOpen", true),
       },
       },
     },
     },
-    newLayoutDesigns: true,
   }
   }
   const addReviewComment = () => {
   const addReviewComment = () => {
     const comment = controls.comments + 1
     const comment = controls.comments + 1
@@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
       },
       },
       reviewPanel: { opened: () => false, open: () => {} },
       reviewPanel: { opened: () => false, open: () => {} },
     },
     },
-    newLayoutDesigns: true,
   }
   }
   const state = {
   const state = {
     blocked: () => false,
     blocked: () => false,

+ 362 - 813
packages/app/src/components/prompt-input.tsx

@@ -13,12 +13,11 @@ import {
   Match,
   Match,
   type JSX,
   type JSX,
 } from "solid-js"
 } from "solid-js"
-import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
-import type { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import {
 import {
   ContentPart,
   ContentPart,
   DEFAULT_PROMPT,
   DEFAULT_PROMPT,
+  isCommentItem,
   isPromptEqual,
   isPromptEqual,
   Prompt,
   Prompt,
   usePrompt,
   usePrompt,
@@ -36,6 +35,8 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
 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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -43,8 +44,9 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
 import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
+import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
 import { useCommand } from "@/context/command"
 import { useCommand } from "@/context/command"
-import { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
@@ -55,121 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
 import {
 import {
   canNavigateHistoryAtCursor,
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
   navigatePromptHistory,
-  prependHistoryEntry,
   type PromptHistoryComment,
   type PromptHistoryComment,
   type PromptHistoryEntry,
   type PromptHistoryEntry,
-  type PromptHistoryStoredEntry,
   promptLength,
   promptLength,
 } from "./prompt-input/history"
 } from "./prompt-input/history"
-import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
+import {
+  createPersistedPromptInputHistory,
+  createPromptInputHistory,
+  type PromptInputHistory,
+} from "./prompt-input/history-store"
+import {
+  type PromptInputControls,
+  type PromptInputProps,
+  type PromptInputState,
+  type PromptInputSubmission,
+} from "./prompt-input/contracts"
+import { createPromptSubmit } from "./prompt-input/submit"
 import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
 import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
 import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
-import { promptPlaceholder } from "./prompt-input/placeholder"
+import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
 import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { showToast } from "@/utils/toast"
 import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
 import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
 
 
-export type PromptInputState = ReturnType<typeof usePrompt>
-
-export type PromptInputHistory = {
-  entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
-  add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
-}
-
-export type PromptInputSubmission = {
-  abort: () => Promise<void> | void
-  handleSubmit: (event: Event) => Promise<void> | void
-}
-
-export type PromptInputControls = {
-  agents: {
-    available: { name: string; hidden?: boolean; mode: string }[]
-    options: string[]
-    current: string
-    loading: boolean
-    visible: boolean
-    select: (name: string | undefined) => void
-  }
-  model: {
-    selection: ReturnType<typeof useLocal>["model"]
-    paid: boolean
-    loading: boolean
-  }
-  session: {
-    id?: string
-    tabs: {
-      active: () => string | undefined
-      all: () => string[]
-      open: (tab: string) => void | Promise<void>
-      setActive: (tab: string) => void
-    }
-    reviewPanel: {
-      opened: () => boolean
-      open: () => void
-    }
-  }
-  newLayoutDesigns: boolean
-}
-
-export function createPromptInputHistory(): PromptInputHistory {
-  const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
-  const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
-  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
-}
-
-type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
-
-function createPromptInputHistoryStore(
-  normal: Store<PromptHistoryState>,
-  setNormal: SetStoreFunction<PromptHistoryState>,
-  shell: Store<PromptHistoryState>,
-  setShell: SetStoreFunction<PromptHistoryState>,
-): PromptInputHistory {
-  return {
-    entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
-    add(prompt, mode, comments) {
-      const current = mode === "shell" ? shell : normal
-      const setCurrent = mode === "shell" ? setShell : setNormal
-      const next = prependHistoryEntry(current.entries, prompt, comments)
-      if (next === current.entries) return
-      setCurrent("entries", next)
-    },
-  }
-}
-
-function createPersistedPromptInputHistory() {
-  const [normal, setNormal] = persisted(
-    Persist.global("prompt-history", ["prompt-history.v1"]),
-    createStore<PromptHistoryState>({ entries: [] }),
-  )
-  const [shell, setShell] = persisted(
-    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
-    createStore<PromptHistoryState>({ entries: [] }),
-  )
-  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
-}
-
-export interface PromptInputProps {
-  class?: string
-  variant?: "dock" | "new-session"
-  state?: PromptInputState
-  history?: PromptInputHistory
-  submission?: PromptInputSubmission
-  controls: PromptInputControls
-  ref?: (el: HTMLDivElement) => void
-  newSessionWorktree?: string
-  onNewSessionWorktreeReset?: () => void
-  edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
-  onEditLoaded?: () => void
-  shouldQueue?: () => boolean
-  onQueue?: (draft: FollowupDraft) => void
-  onAbort?: () => void
-  onSubmit?: () => void
-  toolbar?: JSX.Element
-}
+export { createPromptInputHistory }
+export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
 
 
 const EXAMPLES = [
 const EXAMPLES = [
   "prompt.example.1",
   "prompt.example.1",
@@ -520,7 +435,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
   const setMode = (mode: "normal" | "shell") => {
   const setMode = (mode: "normal" | "shell") => {
     setStore("mode", mode)
     setStore("mode", mode)
-    setStore("popover", null)
+    setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
     requestAnimationFrame(() => editorRef?.focus())
     requestAnimationFrame(() => editorRef?.focus())
   }
   }
 
 
@@ -554,7 +469,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     },
     },
   ])
   ])
 
 
-  const closePopover = () => setStore("popover", null)
+  const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
 
 
   const resetHistoryNavigation = (force = false) => {
   const resetHistoryNavigation = (force = false) => {
     if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
     if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
@@ -628,7 +543,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)
   }
   }
@@ -800,17 +717,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
   const handleSlashSelect = (cmd: SlashCommand | undefined) => {
   const handleSlashSelect = (cmd: SlashCommand | undefined) => {
     if (!cmd) return
     if (!cmd) return
+    const menu = store.slashMenu
     closePopover()
     closePopover()
     const images = imageAttachments()
     const images = imageAttachments()
 
 
     if (cmd.type === "custom") {
     if (cmd.type === "custom") {
       const text = `/${cmd.trigger} `
       const text = `/${cmd.trigger} `
+      if (menu) {
+        editorRef.focus()
+        setCursorPosition(editorRef, 0)
+        addPart({ type: "text", content: text, start: 0, end: text.length })
+        focusEditorEnd()
+        return
+      }
       setEditorText(text)
       setEditorText(text)
       prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
       prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
       focusEditorEnd()
       focusEditorEnd()
       return
       return
     }
     }
 
 
+    if (menu) {
+      command.trigger(cmd.id, "slash")
+      return
+    }
+
     clearEditor()
     clearEditor()
     prompt.set([...DEFAULT_PROMPT, ...images], 0)
     prompt.set([...DEFAULT_PROMPT, ...images], 0)
     command.trigger(cmd.id, "slash")
     command.trigger(cmd.id, "slash")
@@ -1072,10 +1002,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
       if (atMatch) {
       if (atMatch) {
         atOnInput(atMatch[1])
         atOnInput(atMatch[1])
-        setStore("popover", "at")
+        setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
       } else if (slashMatch) {
       } else if (slashMatch) {
         slashOnInput(slashMatch[1])
         slashOnInput(slashMatch[1])
-        setStore("popover", "slash")
+        setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
       } else {
       } else {
         closePopover()
         closePopover()
       }
       }
@@ -1199,7 +1129,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }
         }
 
 
         setStore("mode", "normal")
         setStore("mode", "normal")
-        setStore("popover", null)
+        closePopover()
         setStore("historyIndex", -1)
         setStore("historyIndex", -1)
         setStore("savedPrompt", null)
         setStore("savedPrompt", null)
         prompt.set(edit.prompt, promptLength(edit.prompt))
         prompt.set(edit.prompt, promptLength(edit.prompt))
@@ -1286,13 +1216,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         resetHistoryNavigation(true)
         resetHistoryNavigation(true)
       },
       },
       setMode: (mode) => setStore("mode", mode),
       setMode: (mode) => setStore("mode", mode),
-      setPopover: (popover) => setStore("popover", popover),
+      setPopover: (popover) => {
+        if (!popover) return closePopover()
+        setStore({ popover, slashMenu: false, slashMenuQuery: "" })
+      },
       newSessionWorktree: () => props.newSessionWorktree,
       newSessionWorktree: () => props.newSessionWorktree,
       onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
       onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
       shouldQueue: props.shouldQueue,
       shouldQueue: props.shouldQueue,
       onQueue: props.onQueue,
       onQueue: props.onQueue,
       onAbort: props.onAbort,
       onAbort: props.onAbort,
       onSubmit: props.onSubmit,
       onSubmit: props.onSubmit,
+      model: props.controls.model.selection,
     })
     })
 
 
   const handleKeyDown = (event: KeyboardEvent) => {
   const handleKeyDown = (event: KeyboardEvent) => {
@@ -1325,7 +1259,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const cursorPosition = getCursorPosition(editorRef)
       const cursorPosition = getCursorPosition(editorRef)
       if (cursorPosition === 0) {
       if (cursorPosition === 0) {
         setStore("mode", "shell")
         setStore("mode", "shell")
-        setStore("popover", null)
+        closePopover()
         event.preventDefault()
         event.preventDefault()
         return
         return
       }
       }
@@ -1460,6 +1394,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     }
   }
   }
 
 
+  const handleSlashMenuKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "Escape") {
+      closePopover()
+      requestAnimationFrame(() => editorRef.focus())
+      event.preventDefault()
+      return
+    }
+
+    if (event.key === "Tab") {
+      selectPopoverActive()
+      event.preventDefault()
+      return
+    }
+
+    const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
+    const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
+    const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
+    if (!nav && !ctrlNav) return
+    slashOnKeyDown(event)
+    if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView()
+    event.preventDefault()
+  }
+
   const agentsLoading = () => props.controls.agents.loading
   const agentsLoading = () => props.controls.agents.loading
   const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
   const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
   const providersLoading = () => props.controls.model.loading
   const providersLoading = () => props.controls.model.loading
@@ -1470,48 +1427,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     (p) => p,
     (p) => p,
   )
   )
 
 
-  const designPlaceholder = () => {
-    if (store.mode === "shell") return placeholder()
-    return "Ask anything, / for commands, @ for context..."
-  }
-
-  const modelControlState = createMemo<ComposerModelControlState>(() => ({
-    loading: providersLoading(),
-    shouldAnimate: providersShouldFadeIn(),
-    paid: props.controls.model.paid,
-    title: language.t("command.model.choose"),
-    keybind: command.keybindParts("model.choose"),
-    model: props.controls.model.selection,
-    providerID: props.controls.model.selection.current()?.provider?.id,
-    modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
-    newLayoutDesigns: props.controls.newLayoutDesigns,
-    style: control(),
-    onClose: restoreFocus,
-    onUnpaidClick: () => {
-      void import("@/components/dialog-select-model-unpaid").then((x) => {
-        dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
-      })
-    },
-  }))
-
-  const newSession = () => props.variant === "new-session"
   const bindEditorRef = (el: HTMLDivElement) => {
   const bindEditorRef = (el: HTMLDivElement) => {
     editorRef = el
     editorRef = el
     restoreEndOnFocus = true
     restoreEndOnFocus = true
     props.ref?.(el)
     props.ref?.(el)
   }
   }
-  const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
-  const agentControlState = createMemo<ComposerAgentControlState>(() => ({
-    title: language.t("command.agent.cycle"),
-    keybind: command.keybindParts("agent.cycle"),
-    options: props.controls.agents.options,
-    current: props.controls.agents.current,
-    style: control(),
-    onSelect: (value) => {
-      props.controls.agents.select(value)
-      restoreFocus()
-    },
-  }))
   return (
   return (
     <div class="relative size-full flex flex-col gap-0">
     <div class="relative size-full flex flex-col gap-0">
       {(promptReady(), null)}
       {(promptReady(), null)}
@@ -1527,715 +1447,344 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         slashActive={slashActive() ?? undefined}
         slashActive={slashActive() ?? undefined}
         setSlashActive={setSlashActive}
         setSlashActive={setSlashActive}
         onSlashSelect={handleSlashSelect}
         onSlashSelect={handleSlashSelect}
+        slashMenu={store.slashMenu}
+        slashMenuQuery={store.slashMenuQuery}
+        onSlashMenuInput={(value) => {
+          setStore("slashMenuQuery", value)
+          slashOnInput(value)
+        }}
+        onSlashMenuKeyDown={handleSlashMenuKeyDown}
         commandKeybind={command.keybind}
         commandKeybind={command.keybind}
         commandKeybindParts={command.keybindParts}
         commandKeybindParts={command.keybindParts}
-        newLayoutDesigns={props.controls.newLayoutDesigns}
+        newLayoutDesigns={false}
         t={(key) => language.t(key as Parameters<typeof language.t>[0])}
         t={(key) => language.t(key as Parameters<typeof language.t>[0])}
       />
       />
-      <Switch>
-        <Match when={props.controls.newLayoutDesigns}>
-          <div class="flex flex-col gap-3">
-            <DockShellForm
-              data-component={newSession() ? "session-new-composer" : "session-composer"}
-              onSubmit={handleSubmit}
+      <DockShellForm
+        data-dock-border-underlay="legacy"
+        onSubmit={handleSubmit}
+        classList={{
+          "group/prompt-input": true,
+          "border-icon-info-active border-dashed": store.draggingType !== null,
+          [props.class ?? ""]: !!props.class,
+        }}
+      >
+        <PromptDragOverlay
+          type={store.draggingType}
+          label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
+        />
+        <PromptContextItems
+          items={contextItems()}
+          active={(item) => {
+            const active = comments.active()
+            return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+          }}
+          openComment={openComment}
+          remove={(item) => {
+            if (item.commentID) comments.remove(item.path, item.commentID)
+            prompt.context.remove(item.key)
+          }}
+          newLayoutDesigns={false}
+          t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+        />
+        <PromptImageAttachments
+          attachments={imageAttachments()}
+          onOpen={(attachment) =>
+            dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+          }
+          onRemove={removeAttachment}
+          removeLabel={language.t("prompt.attachment.remove")}
+          newLayoutDesigns={false}
+        />
+        <div
+          class="relative"
+          onMouseDown={(e) => {
+            const target = e.target
+            if (!(target instanceof HTMLElement)) return
+            if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
+              return
+            }
+            editorRef?.focus()
+          }}
+        >
+          <div
+            class="relative max-h-[240px] overflow-y-auto no-scrollbar"
+            ref={(el) => (scrollRef = el)}
+            style={{ "scroll-padding-bottom": space }}
+          >
+            <div
+              data-component="prompt-input"
+              ref={bindEditorRef}
+              role="textbox"
+              aria-multiline="true"
+              aria-label={placeholder()}
+              contenteditable="true"
+              autocapitalize={store.mode === "normal" ? "sentences" : "off"}
+              autocorrect={store.mode === "normal" ? "on" : "off"}
+              spellcheck={store.mode === "normal"}
+              inputMode="text"
+              // @ts-expect-error
+              autocomplete="off"
+              onInput={handleInput}
+              onPaste={handlePaste}
+              onCompositionStart={handleCompositionStart}
+              onCompositionEnd={handleCompositionEnd}
+              onFocus={handleFocus}
+              onBlur={handleBlur}
+              onKeyDown={handleKeyDown}
               classList={{
               classList={{
-                "group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
-                "border-icon-info-active border-dashed": store.draggingType !== null,
-                [props.class ?? ""]: !!props.class,
+                "select-text": true,
+                "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+                "[&_[data-type=file]]:text-syntax-property": true,
+                "[&_[data-type=agent]]:text-syntax-type": true,
+                "font-mono!": store.mode === "shell",
               }}
               }}
+              style={{ "padding-bottom": space }}
+            />
+            <div
+              class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
+              classList={{ "font-mono!": store.mode === "shell" }}
+              style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
             >
             >
-              <PromptDragOverlay
-                type={store.draggingType}
-                label={language.t(
-                  store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
-                )}
-              />
-              <PromptContextItems
-                items={contextItems()}
-                active={(item) => {
-                  const active = comments.active()
-                  return !!item.commentID && item.commentID === active?.id && item.path === active?.file
-                }}
-                openComment={openComment}
-                remove={(item) => {
-                  if (item.commentID) comments.remove(item.path, item.commentID)
-                  prompt.context.remove(item.key)
-                }}
-                t={(key) => language.t(key as Parameters<typeof language.t>[0])}
-              />
-              <PromptImageAttachments
-                attachments={imageAttachments()}
-                onOpen={(attachment) =>
-                  dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
-                }
-                onRemove={removeAttachment}
-                removeLabel={language.t("prompt.attachment.remove")}
-              />
-              <div
-                class="relative min-h-[52px]"
-                onMouseDown={(e) => {
-                  const target = e.target
-                  if (!(target instanceof HTMLElement)) return
-                  if (target.closest('[data-action^="prompt-"]')) return
-                  editorRef?.focus()
-                }}
-              >
-                <div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
-                  <div
-                    data-component="prompt-input"
-                    ref={bindEditorRef}
-                    role="textbox"
-                    aria-multiline="true"
-                    aria-label={designPlaceholder()}
-                    contenteditable="true"
-                    autocapitalize={store.mode === "normal" ? "sentences" : "off"}
-                    autocorrect={store.mode === "normal" ? "on" : "off"}
-                    spellcheck={store.mode === "normal"}
-                    inputMode="text"
-                    // @ts-expect-error
-                    autocomplete="off"
-                    onInput={handleInput}
-                    onPaste={handlePaste}
-                    onCompositionStart={handleCompositionStart}
-                    onCompositionEnd={handleCompositionEnd}
-                    onFocus={handleFocus}
-                    onBlur={handleBlur}
-                    onKeyDown={handleKeyDown}
-                    classList={{
-                      "select-text": true,
-                      "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
-                      "[&_[data-type=file]]:text-syntax-property": true,
-                      "[&_[data-type=agent]]:text-syntax-type": true,
-                      "font-mono!": store.mode === "shell",
-                    }}
-                  />
-                  <div
-                    data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
-                    class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
-                    classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
-                  >
-                    {designPlaceholder()}
-                  </div>
-                </div>
-              </div>
-              <div class="flex h-11 items-center px-2">
-                <div class="flex min-w-0 flex-1 items-center gap-1">
-                  {fileAttachmentInput()}
-                  <TooltipV2
-                    placement="top"
-                    value={
-                      <>
-                        {language.t("prompt.action.attachFile")}
-                        <KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
-                      </>
-                    }
-                  >
-                    <IconButton
-                      data-action="prompt-attach"
-                      type="button"
-                      icon="plus"
-                      variant="ghost"
-                      class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
-                      style={buttons()}
-                      onClick={pick}
-                      disabled={store.mode !== "normal"}
-                      tabIndex={store.mode === "normal" ? undefined : -1}
-                      aria-label={language.t("prompt.action.attachFile")}
-                    />
-                  </TooltipV2>
-                  <Show when={showAgentControl()}>
-                    <ComposerAgentControl state={agentControlState()} />
-                  </Show>
-                  {props.toolbar}
-                  <ComposerModelControl state={modelControlState()} />
-                  <Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
-                    <div
-                      data-component="prompt-variant-control"
-                      class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
-                      classList={{
-                        "animate-in fade-in": providersShouldFadeIn(),
-                        "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
-                          !props.controls.model.selection.variant.current() && !store.variantOpen,
-                      }}
-                    >
-                      <TooltipV2
-                        placement="top"
-                        gutter={4}
-                        value={
-                          <>
-                            {language.t("command.model.variant.cycle")}
-                            <KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
-                          </>
-                        }
-                      >
-                        <MenuV2
-                          gutter={6}
-                          modal={false}
-                          placement="top-start"
-                          onOpenChange={(open) => setStore("variantOpen", open)}
-                        >
-                          <MenuV2.Trigger
-                            as={ButtonV2}
-                            data-action="prompt-model-variant"
-                            variant="ghost-muted"
-                            size="normal"
-                            class="max-w-[160px] justify-start capitalize"
-                            style={control()}
-                          >
-                            <span class="truncate">
-                              {props.controls.model.selection.variant.current() ?? language.t("common.default")}
-                            </span>
-                            <span class="-ml-0.5 -mr-1 flex shrink-0">
-                              <Icon name="chevron-down" size="small" />
-                            </span>
-                          </MenuV2.Trigger>
-                          <MenuV2.Portal>
-                            <MenuV2.Content>
-                              <MenuV2.RadioGroup
-                                value={props.controls.model.selection.variant.current() ?? "default"}
-                                onChange={(value) => {
-                                  props.controls.model.selection.variant.set(value === "default" ? undefined : value)
-                                  restoreFocus()
-                                }}
-                              >
-                                {variants().map((value) => (
-                                  <MenuV2.RadioItem value={value} class="capitalize">
-                                    {value === "default" ? language.t("common.default") : value}
-                                  </MenuV2.RadioItem>
-                                ))}
-                              </MenuV2.RadioGroup>
-                            </MenuV2.Content>
-                          </MenuV2.Portal>
-                        </MenuV2>
-                      </TooltipV2>
-                    </div>
-                  </Show>
-                </div>
-                <TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
-                  <IconButton
-                    data-action="prompt-submit"
-                    type="submit"
-                    disabled={!working() && blank()}
-                    tabIndex={store.mode === "normal" ? undefined : -1}
-                    icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
-                    variant="primary"
-                    class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
-                    style={{
-                      "background-image":
-                        "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
-                    }}
-                    aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
-                  />
-                </TooltipV2>
-              </div>
-            </DockShellForm>
+              {placeholder()}
+            </div>
           </div>
           </div>
-        </Match>
-        <Match when>
-          <DockShellForm
-            onSubmit={handleSubmit}
-            classList={{
-              "group/prompt-input": true,
-              "focus-within:shadow-xs-border": true,
-              "border-icon-info-active border-dashed": store.draggingType !== null,
-              [props.class ?? ""]: !!props.class,
+
+          <div
+            aria-hidden="true"
+            class="pointer-events-none absolute inset-x-0 bottom-0"
+            style={{
+              height: space,
+              background:
+                "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
             }}
             }}
-          >
-            <PromptDragOverlay
-              type={store.draggingType}
-              label={language.t(
-                store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
-              )}
-            />
-            <PromptContextItems
-              items={contextItems()}
-              active={(item) => {
-                const active = comments.active()
-                return !!item.commentID && item.commentID === active?.id && item.path === active?.file
-              }}
-              openComment={openComment}
-              remove={(item) => {
-                if (item.commentID) comments.remove(item.path, item.commentID)
-                prompt.context.remove(item.key)
+          />
+
+          <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
+            <input
+              ref={fileInputRef}
+              type="file"
+              multiple
+              accept={ACCEPTED_FILE_TYPES.join(",")}
+              class="hidden"
+              onChange={(e) => {
+                const list = e.currentTarget.files
+                if (list) void addAttachments(Array.from(list))
+                e.currentTarget.value = ""
               }}
               }}
-              t={(key) => language.t(key as Parameters<typeof language.t>[0])}
-            />
-            <PromptImageAttachments
-              attachments={imageAttachments()}
-              onOpen={(attachment) =>
-                dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
-              }
-              onRemove={removeAttachment}
-              removeLabel={language.t("prompt.attachment.remove")}
             />
             />
+
+            <div class="flex items-center gap-1 pointer-events-auto">
+              <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
+                <IconButton
+                  data-action="prompt-submit"
+                  type="submit"
+                  disabled={!working() && blank()}
+                  tabIndex={store.mode === "normal" ? undefined : -1}
+                  icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
+                  variant="primary"
+                  class="size-8"
+                  aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
+                />
+              </Tooltip>
+            </div>
+          </div>
+
+          <div class="pointer-events-none absolute bottom-2 left-2">
             <div
             <div
-              class="relative"
-              onMouseDown={(e) => {
-                const target = e.target
-                if (!(target instanceof HTMLElement)) return
-                if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
-                  return
-                }
-                editorRef?.focus()
+              aria-hidden={store.mode !== "normal"}
+              class="pointer-events-auto"
+              style={{
+                "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
               }}
               }}
             >
             >
-              <div
-                class="relative max-h-[240px] overflow-y-auto no-scrollbar"
-                ref={(el) => (scrollRef = el)}
-                style={{ "scroll-padding-bottom": space }}
+              <TooltipKeybind
+                placement="top"
+                title={language.t("prompt.action.attachFile")}
+                keybind={command.keybind("file.attach")}
               >
               >
-                <div
-                  data-component="prompt-input"
-                  ref={bindEditorRef}
-                  role="textbox"
-                  aria-multiline="true"
-                  aria-label={placeholder()}
-                  contenteditable="true"
-                  autocapitalize={store.mode === "normal" ? "sentences" : "off"}
-                  autocorrect={store.mode === "normal" ? "on" : "off"}
-                  spellcheck={store.mode === "normal"}
-                  inputMode="text"
-                  // @ts-expect-error
-                  autocomplete="off"
-                  onInput={handleInput}
-                  onPaste={handlePaste}
-                  onCompositionStart={handleCompositionStart}
-                  onCompositionEnd={handleCompositionEnd}
-                  onFocus={handleFocus}
-                  onBlur={handleBlur}
-                  onKeyDown={handleKeyDown}
-                  classList={{
-                    "select-text": true,
-                    "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
-                    "[&_[data-type=file]]:text-syntax-property": true,
-                    "[&_[data-type=agent]]:text-syntax-type": true,
-                    "font-mono!": store.mode === "shell",
-                  }}
-                  style={{ "padding-bottom": space }}
-                />
-                <div
-                  class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
-                  classList={{ "font-mono!": store.mode === "shell" }}
-                  style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
+                <Button
+                  data-action="prompt-attach"
+                  type="button"
+                  variant="ghost"
+                  class="size-8 p-0"
+                  style={buttons()}
+                  onClick={pick}
+                  disabled={store.mode !== "normal"}
+                  tabIndex={store.mode === "normal" ? undefined : -1}
+                  aria-label={language.t("prompt.action.attachFile")}
                 >
                 >
-                  {placeholder()}
-                </div>
-              </div>
-
+                  <Icon name="plus" class="size-4.5" />
+                </Button>
+              </TooltipKeybind>
+            </div>
+          </div>
+        </div>
+      </DockShellForm>
+      <Show when={store.mode === "normal" || store.mode === "shell"}>
+        <DockTray attach="top">
+          <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
+            <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
               <div
               <div
-                aria-hidden="true"
-                class="pointer-events-none absolute inset-x-0 bottom-0"
+                class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
                 style={{
                 style={{
-                  height: space,
-                  background:
-                    "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
+                  padding: "0 0px 0 8px",
+                  ...shell(),
                 }}
                 }}
-              />
-
-              <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
-                <input
-                  ref={fileInputRef}
-                  type="file"
-                  multiple
-                  accept={ACCEPTED_FILE_TYPES.join(",")}
-                  class="hidden"
-                  onChange={(e) => {
-                    const list = e.currentTarget.files
-                    if (list) void addAttachments(Array.from(list))
-                    e.currentTarget.value = ""
-                  }}
-                />
-
-                <div class="flex items-center gap-1 pointer-events-auto">
-                  <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
-                    <IconButton
-                      data-action="prompt-submit"
-                      type="submit"
-                      disabled={!working() && blank()}
-                      tabIndex={store.mode === "normal" ? undefined : -1}
-                      icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
-                      variant="primary"
-                      class="size-8"
-                      aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
-                    />
-                  </Tooltip>
-                </div>
-              </div>
-
-              <div class="pointer-events-none absolute bottom-2 left-2">
-                <div
-                  aria-hidden={store.mode !== "normal"}
-                  class="pointer-events-auto"
-                  style={{
-                    "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+              >
+                <Icon name="console" />
+                <span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
+                <div class="flex-1" />
+                <Button
+                  variant="ghost"
+                  class="text-text-base"
+                  onClick={() => {
+                    setStore("mode", "normal")
                   }}
                   }}
                 >
                 >
-                  <TooltipKeybind
-                    placement="top"
-                    title={language.t("prompt.action.attachFile")}
-                    keybind={command.keybind("file.attach")}
-                  >
-                    <Button
-                      data-action="prompt-attach"
-                      type="button"
-                      variant="ghost"
-                      class="size-8 p-0"
-                      style={buttons()}
-                      onClick={pick}
-                      disabled={store.mode !== "normal"}
-                      tabIndex={store.mode === "normal" ? undefined : -1}
-                      aria-label={language.t("prompt.action.attachFile")}
-                    >
-                      <Icon name="plus" class="size-4.5" />
-                    </Button>
-                  </TooltipKeybind>
-                </div>
+                  {language.t("common.cancel")}
+                </Button>
               </div>
               </div>
-            </div>
-          </DockShellForm>
-          <Show when={store.mode === "normal" || store.mode === "shell"}>
-            <DockTray attach="top">
-              <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
-                <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
+              <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
+                <Show when={!agentsLoading()}>
                   <div
                   <div
-                    class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
-                    style={{
-                      padding: "0 0px 0 8px",
-                      ...shell(),
-                    }}
+                    data-component="prompt-agent-control"
+                    classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
                   >
                   >
-                    <Icon name="console" />
-                    <span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
-                    <div class="flex-1" />
-                    <Button
-                      variant="ghost"
-                      class="text-text-base"
-                      onClick={() => {
-                        setStore("mode", "normal")
-                      }}
+                    <TooltipKeybind
+                      placement="top"
+                      gutter={4}
+                      title={language.t("command.agent.cycle")}
+                      keybind={command.keybind("agent.cycle")}
                     >
                     >
-                      {language.t("common.cancel")}
-                    </Button>
+                      <Select
+                        size="normal"
+                        options={props.controls.agents.options}
+                        current={props.controls.agents.current}
+                        onSelect={(value) => {
+                          props.controls.agents.select(value)
+                          restoreFocus()
+                        }}
+                        class="capitalize max-w-[160px] text-text-base"
+                        valueClass="truncate text-13-regular text-text-base"
+                        triggerStyle={control()}
+                        triggerProps={{ "data-action": "prompt-agent" }}
+                        variant="ghost"
+                      />
+                    </TooltipKeybind>
                   </div>
                   </div>
-                  <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
-                    <Show when={!agentsLoading()}>
+                </Show>
+                <Show when={!providersLoading()}>
+                  <Show when={store.mode !== "shell"}>
+                    <div
+                      data-component="prompt-model-control"
+                      classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
+                    >
+                      <Show
+                        when={props.controls.model.paid}
+                        fallback={
+                          <TooltipKeybind
+                            placement="top"
+                            gutter={4}
+                            title={language.t("command.model.choose")}
+                            keybind={command.keybind("model.choose")}
+                          >
+                            <Button
+                              data-action="prompt-model"
+                              as="div"
+                              variant="ghost"
+                              size="normal"
+                              class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
+                              style={control()}
+                              onClick={() => {
+                                dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
+                              }}
+                            >
+                              <Show when={props.controls.model.selection.current()?.provider?.id}>
+                                <ProviderIcon
+                                  id={props.controls.model.selection.current()?.provider?.id ?? ""}
+                                  class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+                                  style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                                />
+                              </Show>
+                              <span class="truncate">
+                                {props.controls.model.selection.current()?.name ??
+                                  language.t("dialog.model.select.title")}
+                              </span>
+                              <Icon name="chevron-down" size="small" class="shrink-0" />
+                            </Button>
+                          </TooltipKeybind>
+                        }
+                      >
+                        <TooltipKeybind
+                          placement="top"
+                          gutter={4}
+                          title={language.t("command.model.choose")}
+                          keybind={command.keybind("model.choose")}
+                        >
+                          <ModelSelectorPopover
+                            model={props.controls.model.selection}
+                            triggerAs={Button}
+                            triggerProps={{
+                              variant: "ghost",
+                              size: "normal",
+                              style: control(),
+                              class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
+                              "data-action": "prompt-model",
+                            }}
+                            onClose={restoreFocus}
+                          >
+                            <Show when={props.controls.model.selection.current()?.provider?.id}>
+                              <ProviderIcon
+                                id={props.controls.model.selection.current()?.provider?.id ?? ""}
+                                class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+                                style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                              />
+                            </Show>
+                            <span class="truncate">
+                              {props.controls.model.selection.current()?.name ??
+                                language.t("dialog.model.select.title")}
+                            </span>
+                            <Icon name="chevron-down" size="small" class="shrink-0" />
+                          </ModelSelectorPopover>
+                        </TooltipKeybind>
+                      </Show>
+                    </div>
+                    <Show when={showVariantControl()}>
                       <div
                       <div
-                        data-component="prompt-agent-control"
-                        classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
+                        data-component="prompt-variant-control"
+                        classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
                       >
                       >
                         <TooltipKeybind
                         <TooltipKeybind
                           placement="top"
                           placement="top"
                           gutter={4}
                           gutter={4}
-                          title={language.t("command.agent.cycle")}
-                          keybind={command.keybind("agent.cycle")}
+                          title={language.t("command.model.variant.cycle")}
+                          keybind={command.keybind("model.variant.cycle")}
                         >
                         >
                           <Select
                           <Select
                             size="normal"
                             size="normal"
-                            options={props.controls.agents.options}
-                            current={props.controls.agents.current}
+                            options={variants()}
+                            current={props.controls.model.selection.variant.current() ?? "default"}
+                            label={(x) => (x === "default" ? language.t("common.default") : x)}
                             onSelect={(value) => {
                             onSelect={(value) => {
-                              props.controls.agents.select(value)
+                              props.controls.model.selection.variant.set(value === "default" ? undefined : value)
                               restoreFocus()
                               restoreFocus()
                             }}
                             }}
                             class="capitalize max-w-[160px] text-text-base"
                             class="capitalize max-w-[160px] text-text-base"
                             valueClass="truncate text-13-regular text-text-base"
                             valueClass="truncate text-13-regular text-text-base"
                             triggerStyle={control()}
                             triggerStyle={control()}
-                            triggerProps={{ "data-action": "prompt-agent" }}
+                            triggerProps={{ "data-action": "prompt-model-variant" }}
                             variant="ghost"
                             variant="ghost"
                           />
                           />
                         </TooltipKeybind>
                         </TooltipKeybind>
                       </div>
                       </div>
                     </Show>
                     </Show>
-                    <Show when={!providersLoading()}>
-                      <Show when={store.mode !== "shell"}>
-                        <div
-                          data-component="prompt-model-control"
-                          classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
-                        >
-                          <Show
-                            when={props.controls.model.paid}
-                            fallback={
-                              <TooltipKeybind
-                                placement="top"
-                                gutter={4}
-                                title={language.t("command.model.choose")}
-                                keybind={command.keybind("model.choose")}
-                              >
-                                <Button
-                                  data-action="prompt-model"
-                                  as="div"
-                                  variant="ghost"
-                                  size="normal"
-                                  class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
-                                  style={control()}
-                                  onClick={() => {
-                                    void import("@/components/dialog-select-model-unpaid").then((x) => {
-                                      dialog.show(() => (
-                                        <x.DialogSelectModelUnpaid model={props.controls.model.selection} />
-                                      ))
-                                    })
-                                  }}
-                                >
-                                  <Show when={props.controls.model.selection.current()?.provider?.id}>
-                                    <ProviderIcon
-                                      id={props.controls.model.selection.current()?.provider?.id ?? ""}
-                                      class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                                      style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                                    />
-                                  </Show>
-                                  <span class="truncate">
-                                    {props.controls.model.selection.current()?.name ??
-                                      language.t("dialog.model.select.title")}
-                                  </span>
-                                  <Icon name="chevron-down" size="small" class="shrink-0" />
-                                </Button>
-                              </TooltipKeybind>
-                            }
-                          >
-                            <TooltipKeybind
-                              placement="top"
-                              gutter={4}
-                              title={language.t("command.model.choose")}
-                              keybind={command.keybind("model.choose")}
-                            >
-                              <ModelSelectorPopover
-                                model={props.controls.model.selection}
-                                triggerAs={Button}
-                                triggerProps={{
-                                  variant: "ghost",
-                                  size: "normal",
-                                  style: control(),
-                                  class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
-                                  "data-action": "prompt-model",
-                                }}
-                                onClose={restoreFocus}
-                              >
-                                <Show when={props.controls.model.selection.current()?.provider?.id}>
-                                  <ProviderIcon
-                                    id={props.controls.model.selection.current()?.provider?.id ?? ""}
-                                    class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                                    style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                                  />
-                                </Show>
-                                <span class="truncate">
-                                  {props.controls.model.selection.current()?.name ??
-                                    language.t("dialog.model.select.title")}
-                                </span>
-                                <Icon name="chevron-down" size="small" class="shrink-0" />
-                              </ModelSelectorPopover>
-                            </TooltipKeybind>
-                          </Show>
-                        </div>
-                        <Show when={showVariantControl()}>
-                          <div
-                            data-component="prompt-variant-control"
-                            classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
-                          >
-                            <TooltipKeybind
-                              placement="top"
-                              gutter={4}
-                              title={language.t("command.model.variant.cycle")}
-                              keybind={command.keybind("model.variant.cycle")}
-                            >
-                              <Select
-                                size="normal"
-                                options={variants()}
-                                current={props.controls.model.selection.variant.current() ?? "default"}
-                                label={(x) => (x === "default" ? language.t("common.default") : x)}
-                                onSelect={(value) => {
-                                  props.controls.model.selection.variant.set(value === "default" ? undefined : value)
-                                  restoreFocus()
-                                }}
-                                class="capitalize max-w-[160px] text-text-base"
-                                valueClass="truncate text-13-regular text-text-base"
-                                triggerStyle={control()}
-                                triggerProps={{ "data-action": "prompt-model-variant" }}
-                                variant="ghost"
-                              />
-                            </TooltipKeybind>
-                          </div>
-                        </Show>
-                      </Show>
-                    </Show>
-                  </div>
-                </div>
+                  </Show>
+                </Show>
               </div>
               </div>
-            </DockTray>
-          </Show>
-        </Match>
-      </Switch>
-    </div>
-  )
-}
-
-type ComposerAgentControlState = {
-  title: string
-  keybind: string[]
-  options: string[]
-  current: string
-  style: JSX.CSSProperties | undefined
-  onSelect: (value: string | undefined) => void
-}
-
-type ComposerModelControlState = {
-  loading: boolean
-  shouldAnimate: boolean
-  paid: boolean
-  title: string
-  keybind: string[]
-  model: ReturnType<typeof useLocal>["model"]
-  providerID?: string
-  modelName: string
-  newLayoutDesigns: boolean
-  style: JSX.CSSProperties | undefined
-  onClose: () => void
-  onUnpaidClick: () => void
-}
-
-function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
-  return (
-    <div class="relative">
-      <div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
-        <Icon name="sliders" size="small" />
-      </div>
-      <TooltipV2
-        placement="top"
-        gutter={4}
-        value={
-          <>
-            {props.state.title}
-            <KeybindV2 keys={props.state.keybind} variant="neutral" />
-          </>
-        }
-      >
-        <Select
-          size="normal"
-          options={props.state.options}
-          current={props.state.current}
-          onSelect={props.state.onSelect}
-          class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
-          valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
-          triggerStyle={props.state.style}
-          triggerProps={{ "data-action": "prompt-agent" }}
-          variant="ghost"
-        />
-      </TooltipV2>
-    </div>
-  )
-}
-
-function ComposerModelControl(props: { state: ComposerModelControlState }) {
-  return (
-    <Show when={!props.state.loading}>
-      <Show
-        when={props.state.paid}
-        fallback={
-          <TooltipV2
-            placement="top"
-            gutter={4}
-            value={
-              <>
-                {props.state.title}
-                <KeybindV2 keys={props.state.keybind} variant="neutral" />
-              </>
-            }
-          >
-            <Button
-              data-action="prompt-model"
-              as="div"
-              variant="ghost"
-              size="normal"
-              class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
-              classList={{ "animate-in fade-in": props.state.shouldAnimate }}
-              style={props.state.style}
-              onClick={props.state.onUnpaidClick}
-            >
-              <Show when={props.state.providerID}>
-                {(providerID) => (
-                  <ProviderIcon
-                    id={providerID()}
-                    class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                    style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                  />
-                )}
-              </Show>
-              <span class="truncate">{props.state.modelName}</span>
-              <span class="-ml-1 shrink-0 flex size-fit">
-                <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
-              </span>
-            </Button>
-          </TooltipV2>
-        }
-      >
-        <TooltipV2
-          placement="top"
-          gutter={4}
-          value={
-            <>
-              {props.state.title}
-              <KeybindV2 keys={props.state.keybind} variant="neutral" />
-            </>
-          }
-        >
-          <Show
-            when={props.state.newLayoutDesigns}
-            fallback={
-              <ModelSelectorPopover
-                model={props.state.model}
-                triggerAs={Button}
-                triggerProps={{
-                  variant: "ghost",
-                  size: "normal",
-                  style: props.state.style,
-                  class:
-                    "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
-                  classList: { "animate-in fade-in": props.state.shouldAnimate },
-                  "data-action": "prompt-model",
-                }}
-                onClose={props.state.onClose}
-              >
-                <ModelControlContent state={props.state} />
-              </ModelSelectorPopover>
-            }
-          >
-            <ModelSelectorPopoverV2
-              model={props.state.model}
-              triggerAs={ButtonV2}
-              triggerProps={{
-                variant: "ghost-muted",
-                size: "normal",
-                style: props.state.style,
-                class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
-                classList: { "animate-in fade-in": props.state.shouldAnimate },
-                "data-action": "prompt-model",
-              }}
-              onClose={props.state.onClose}
-            >
-              <ModelControlContent state={props.state} v2 />
-            </ModelSelectorPopoverV2>
-          </Show>
-        </TooltipV2>
-      </Show>
-    </Show>
-  )
-}
-
-function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
-  return (
-    <>
-      <Show when={props.state.providerID}>
-        {(providerID) => (
-          <ProviderIcon
-            id={providerID()}
-            class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-            style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-          />
-        )}
+            </div>
+          </div>
+        </DockTray>
       </Show>
       </Show>
-      <span class="truncate">{props.state.modelName}</span>
-      <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" />
-      </span>
-    </>
+    </div>
   )
   )
 }
 }

+ 1 - 1
packages/app/src/components/prompt-input/attachments.ts

@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
   getPathForFile?: (file: File) => string
   getPathForFile?: (file: File) => string
 }
 }
 
 
-type PromptAttachmentsInput = {
+export type PromptAttachmentsInput = {
   prompt: ReturnType<typeof usePrompt>
   prompt: ReturnType<typeof usePrompt>
   editor: () => HTMLDivElement | undefined
   editor: () => HTMLDivElement | undefined
   isDialogActive: () => boolean
   isDialogActive: () => boolean

+ 9 - 7
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
 }
 }
 
 
 export function buildRequestParts(input: BuildRequestPartsInput) {
 export function buildRequestParts(input: BuildRequestPartsInput) {
-  const requestParts: PromptRequestPart[] = [
-    {
-      id: Identifier.ascending("part"),
-      type: "text",
-      text: input.text,
-    },
-  ]
+  const requestParts: PromptRequestPart[] = input.text.trim()
+    ? [
+        {
+          id: Identifier.ascending("part"),
+          type: "text",
+          text: input.text,
+        },
+      ]
+    : []
 
 
   const files = input.prompt.filter(isFileAttachment).map((attachment) => {
   const files = input.prompt.filter(isFileAttachment).map((attachment) => {
     const path = absolute(input.sessionDirectory, attachment.path)
     const path = absolute(input.sessionDirectory, attachment.path)

+ 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>

+ 57 - 0
packages/app/src/components/prompt-input/contracts.ts

@@ -0,0 +1,57 @@
+import type { useLocal } from "@/context/local"
+import type { Prompt, usePrompt } from "@/context/prompt"
+import type { PromptInputHistory } from "./history-store"
+import type { FollowupDraft } from "./submit"
+
+export type PromptInputState = ReturnType<typeof usePrompt>
+
+export type PromptInputSubmission = {
+  abort: () => Promise<void> | void
+  handleSubmit: (event: Event) => Promise<void> | void
+}
+
+export type PromptInputControls = {
+  agents: {
+    available: { name: string; hidden?: boolean; mode: string }[]
+    options: string[]
+    current: string
+    loading: boolean
+    visible: boolean
+    select: (name: string | undefined) => void
+  }
+  model: {
+    selection: ReturnType<typeof useLocal>["model"]
+    paid: boolean
+    loading: boolean
+  }
+  session: {
+    id?: string
+    tabs: {
+      active: () => string | undefined
+      all: () => string[]
+      open: (tab: string) => void | Promise<void>
+      setActive: (tab: string) => void
+    }
+    reviewPanel: {
+      opened: () => boolean
+      open: () => void
+    }
+  }
+}
+
+export interface PromptInputProps {
+  class?: string
+  state?: PromptInputState
+  history?: PromptInputHistory
+  submission?: PromptInputSubmission
+  controls: PromptInputControls
+  ref?: (el: HTMLDivElement) => void
+  newSessionWorktree?: string
+  onNewSessionWorktreeReset?: () => void
+  edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
+  onEditLoaded?: () => void
+  shouldQueue?: () => boolean
+  onQueue?: (draft: FollowupDraft) => void
+  onAbort?: () => void
+  onSubmit?: () => void
+}

+ 47 - 0
packages/app/src/components/prompt-input/history-store.ts

@@ -0,0 +1,47 @@
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import type { Prompt } from "@/context/prompt"
+import { Persist, persisted } from "@/utils/persist"
+import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
+
+export type PromptInputHistory = {
+  entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
+  add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
+}
+
+type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
+
+function createPromptInputHistoryStore(
+  normal: Store<PromptHistoryState>,
+  setNormal: SetStoreFunction<PromptHistoryState>,
+  shell: Store<PromptHistoryState>,
+  setShell: SetStoreFunction<PromptHistoryState>,
+): PromptInputHistory {
+  return {
+    entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
+    add(prompt, mode, comments) {
+      const current = mode === "shell" ? shell : normal
+      const setCurrent = mode === "shell" ? setShell : setNormal
+      const next = prependHistoryEntry(current.entries, prompt, comments)
+      if (next === current.entries) return
+      setCurrent("entries", next)
+    },
+  }
+}
+
+export function createPromptInputHistory(): PromptInputHistory {
+  const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
+  const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
+  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}
+
+export function createPersistedPromptInputHistory() {
+  const [normal, setNormal] = persisted(
+    Persist.global("prompt-history", ["prompt-history.v1"]),
+    createStore<PromptHistoryState>({ entries: [] }),
+  )
+  const [shell, setShell] = persisted(
+    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
+    createStore<PromptHistoryState>({ entries: [] }),
+  )
+  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}

+ 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>
   )
   )

+ 5 - 0
packages/app/src/components/prompt-input/placeholder.ts

@@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
   if (!input.suggest) return input.t("prompt.placeholder.simple")
   if (!input.suggest) return input.t("prompt.placeholder.simple")
   return input.t("prompt.placeholder.normal", { example: input.example })
   return input.t("prompt.placeholder.normal", { example: input.example })
 }
 }
+
+export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
+  if (mode === "shell") return placeholder
+  return "Ask anything, / for commands, @ for context..."
+}

+ 18 - 0
packages/app/src/components/prompt-input/slash-popover.tsx

@@ -41,6 +41,10 @@ type PromptPopoverProps = {
   slashActive?: string
   slashActive?: string
   setSlashActive: (id: string) => void
   setSlashActive: (id: string) => void
   onSlashSelect: (item: SlashCommand) => void
   onSlashSelect: (item: SlashCommand) => void
+  slashMenu: boolean
+  slashMenuQuery: string
+  onSlashMenuInput: (value: string) => void
+  onSlashMenuKeyDown: (event: KeyboardEvent) => void
   commandKeybind: (id: string) => string | undefined
   commandKeybind: (id: string) => string | undefined
   commandKeybindParts: (id: string) => string[]
   commandKeybindParts: (id: string) => string[]
   newLayoutDesigns: boolean
   newLayoutDesigns: boolean
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
             </Show>
             </Show>
           </Match>
           </Match>
           <Match when={props.popover === "slash"}>
           <Match when={props.popover === "slash"}>
+            <Show when={props.slashMenu}>
+              <div class="px-2 py-1">
+                <input
+                  ref={(el) => requestAnimationFrame(() => el.focus())}
+                  value={props.slashMenuQuery}
+                  onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)}
+                  onKeyDown={props.onSlashMenuKeyDown}
+                  onMouseDown={(event) => event.stopPropagation()}
+                  aria-label={props.t("prompt.menu.commands")}
+                  placeholder="/"
+                  class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint"
+                />
+              </div>
+            </Show>
             <Show
             <Show
               when={props.slashFlat.length > 0}
               when={props.slashFlat.length > 0}
               fallback={
               fallback={

+ 92 - 8
packages/app/src/components/prompt-input/submit.test.ts

@@ -1,11 +1,13 @@
 import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
 import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
-import type { Prompt } from "@/context/prompt"
+import { createStore } from "solid-js/store"
+import type { Prompt, PromptStore } from "@/context/prompt"
+import type { ModelSelection } from "@/context/local"
 
 
 let createPromptSubmit: typeof import("./submit").createPromptSubmit
 let createPromptSubmit: typeof import("./submit").createPromptSubmit
 
 
 const createdClients: string[] = []
 const createdClients: string[] = []
 const createdSessions: string[] = []
 const createdSessions: string[] = []
-const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
+const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
 const optimistic: Array<{
 const optimistic: Array<{
   directory?: string
   directory?: string
   sessionID?: string
   sessionID?: string
@@ -26,13 +28,25 @@ let params: { id?: string } = {}
 let search: { draftId?: string } = {}
 let search: { draftId?: string } = {}
 let selected = "/repo/worktree-a"
 let selected = "/repo/worktree-a"
 let variant: string | undefined
 let variant: string | undefined
+let permissionServer = "server-a"
+let createSessionGate: Promise<void> | undefined
 
 
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
+const [promptStore, setPromptStore] = createStore<PromptStore>({
+  prompt: promptValue,
+  cursor: 0,
+  context: { items: [] },
+})
 const prompt = {
 const prompt = {
+  store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
   ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
   ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
   current: () => promptValue,
   current: () => promptValue,
   cursor: () => 0,
   cursor: () => 0,
   dirty: () => true,
   dirty: () => true,
+  model: {
+    current: () => undefined,
+    set: () => undefined,
+  },
   reset: () => undefined,
   reset: () => undefined,
   set: () => undefined,
   set: () => undefined,
   context: {
   context: {
@@ -51,6 +65,7 @@ const clientFor = (directory: string) => {
   return {
   return {
     session: {
     session: {
       create: async () => {
       create: async () => {
+        await createSessionGate
         createdSessions.push(directory)
         createdSessions.push(directory)
         return {
         return {
           data: {
           data: {
@@ -117,13 +132,14 @@ beforeAll(async () => {
     }),
     }),
   }))
   }))
 
 
-  mock.module("@/context/permission", () => ({
-    usePermission: () => ({
+  mock.module("@/context/permission", () => {
+    const state = (server: string) => ({
       enableAutoAccept(sessionID: string, directory: string) {
       enableAutoAccept(sessionID: string, directory: string) {
-        enabledAutoAccept.push({ sessionID, directory })
+        enabledAutoAccept.push({ server, sessionID, directory })
       },
       },
-    }),
-  }))
+    })
+    return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
+  })
 
 
   mock.module("@/context/server", () => ({
   mock.module("@/context/server", () => ({
     useServer: () => ({ key: "server-key" }),
     useServer: () => ({ key: "server-key" }),
@@ -246,6 +262,8 @@ beforeEach(() => {
   syncedDirectories.length = 0
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
   selected = "/repo/worktree-a"
   variant = undefined
   variant = undefined
+  permissionServer = "server-a"
+  createSessionGate = undefined
   for (const key of Object.keys(storedSessions)) delete storedSessions[key]
   for (const key of Object.keys(storedSessions)) delete storedSessions[key]
 })
 })
 
 
@@ -313,7 +331,40 @@ describe("prompt submit worktree selection", () => {
 
 
     await submit.handleSubmit(event)
     await submit.handleSubmit(event)
 
 
-    expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
+    expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
+  })
+
+  test("keeps auto-accept bound to the submission server", async () => {
+    let release = () => {}
+    createSessionGate = new Promise<void>((resolve) => {
+      release = resolve
+    })
+    const submit = createPromptSubmit({
+      prompt,
+      info: () => undefined,
+      imageAttachments: () => [],
+      commentCount: () => 0,
+      autoAccept: () => true,
+      mode: () => "shell",
+      working: () => false,
+      editor: () => undefined,
+      queueScroll: () => undefined,
+      promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+      addToHistory: () => undefined,
+      resetHistoryNavigation: () => undefined,
+      setMode: () => undefined,
+      setPopover: () => undefined,
+      newSessionWorktree: () => selected,
+      onNewSessionWorktreeReset: () => undefined,
+      onSubmit: () => undefined,
+    })
+
+    const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+    permissionServer = "server-b"
+    release()
+    await result
+
+    expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
   })
   })
 
 
   test("promotes drafts using the selected project's server", async () => {
   test("promotes drafts using the selected project's server", async () => {
@@ -378,6 +429,39 @@ describe("prompt submit worktree selection", () => {
     })
     })
   })
   })
 
 
+  test("uses an injected model selection", async () => {
+    params = { id: "session-1" }
+    const model = {
+      current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
+      variant: { current: () => "draft-variant" },
+    } as unknown as ModelSelection
+    const submit = createPromptSubmit({
+      prompt,
+      info: () => ({ id: "session-1" }),
+      imageAttachments: () => [],
+      commentCount: () => 0,
+      autoAccept: () => false,
+      mode: () => "normal",
+      working: () => false,
+      editor: () => undefined,
+      queueScroll: () => undefined,
+      promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+      addToHistory: () => undefined,
+      resetHistoryNavigation: () => undefined,
+      setMode: () => undefined,
+      setPopover: () => undefined,
+      model,
+    })
+
+    await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+
+    expect(optimistic[0]).toMatchObject({
+      message: {
+        model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
+      },
+    })
+  })
+
   test("seeds new sessions before optimistic prompts are added", async () => {
   test("seeds new sessions before optimistic prompts are added", async () => {
     const submit = createPromptSubmit({
     const submit = createPromptSubmit({
       prompt,
       prompt,

+ 21 - 11
packages/app/src/components/prompt-input/submit.ts

@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Binary } from "@opencode-ai/core/util/binary"
 import { Binary } from "@opencode-ai/core/util/binary"
 import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
 import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
-import { batch, type Accessor } from "solid-js"
+import { batch, startTransition, type Accessor } from "solid-js"
 import { useTabs } from "@/context/tabs"
 import { useTabs } from "@/context/tabs"
 import { useServerSync, type ServerSync } from "@/context/server-sync"
 import { useServerSync, type ServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
 import { useSDK, type DirectorySDK } from "@/context/sdk"
 import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
   onQueue?: (draft: FollowupDraft) => void
   onQueue?: (draft: FollowupDraft) => void
   onAbort?: () => void
   onAbort?: () => void
   onSubmit?: () => void
   onSubmit?: () => void
+  model?: ModelSelection
 }
 }
 
 
 export function createPromptSubmit(input: PromptSubmitInput) {
 export function createPromptSubmit(input: PromptSubmitInput) {
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return
       return
     }
     }
 
 
-    const currentModel = local.model.current()
+    const modelSelection = input.model ?? local.model
+    const currentModel = modelSelection.current()
     const currentAgent = local.agent.current()
     const currentAgent = local.agent.current()
-    const variant = local.model.variant.current()
+    const variant = modelSelection.variant.current()
     if (!currentModel || !currentAgent) {
     if (!currentModel || !currentAgent) {
       showToast({
       showToast({
         title: language.t("prompt.toast.modelAgentRequired.title"),
         title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -313,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     input.resetHistoryNavigation()
     input.resetHistoryNavigation()
 
 
     const projectDirectory = sdk().directory
     const projectDirectory = sdk().directory
+    const permissionState = permission.currentServerState()
     const isNewSession = !params.id
     const isNewSession = !params.id
     const shouldAutoAccept = isNewSession && input.autoAccept()
     const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
     const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -374,13 +377,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       if (created) {
       if (created) {
         seed(sessionDirectory, created)
         seed(sessionDirectory, created)
         session = created
         session = created
-        if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
-        local.session.promote(sessionDirectory, session.id)
-        layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
-        const draftID = search.draftId
-        if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
-        else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
-        submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
+        await startTransition(() => {
+          if (!session) return
+          if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
+          local.session.promote(sessionDirectory, session.id, {
+            agent: currentAgent.name,
+            model: { providerID: currentModel.provider.id, modelID: currentModel.id },
+            variant: variant ?? null,
+          })
+          layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+          const draftID = search.draftId
+          if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
+          else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+          submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
+        })
       }
       }
     }
     }
     if (!session) {
     if (!session) {

+ 6 - 3
packages/app/src/components/prompt-input/transient-state.ts

@@ -4,37 +4,40 @@ import type { PromptHistoryEntry } from "./history"
 
 
 export type PromptInputTransientState = {
 export type PromptInputTransientState = {
   popover: "at" | "slash" | null
   popover: "at" | "slash" | null
+  slashMenu: boolean
+  slashMenuQuery: string
   historyIndex: number
   historyIndex: number
   savedPrompt: PromptHistoryEntry | null
   savedPrompt: PromptHistoryEntry | null
   placeholder: number
   placeholder: number
   draggingType: "image" | "@mention" | null
   draggingType: "image" | "@mention" | null
   mode: "normal" | "shell"
   mode: "normal" | "shell"
   applyingHistory: boolean
   applyingHistory: boolean
-  variantOpen: boolean
 }
 }
 
 
 function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
 function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
   setStore({
   setStore({
     popover: null,
     popover: null,
+    slashMenu: false,
+    slashMenuQuery: "",
     historyIndex: -1,
     historyIndex: -1,
     savedPrompt: null,
     savedPrompt: null,
     draggingType: null,
     draggingType: null,
     mode: "normal",
     mode: "normal",
     applyingHistory: false,
     applyingHistory: false,
-    variantOpen: false,
   })
   })
 }
 }
 
 
 export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
 export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
   const [store, setStore] = createStore<PromptInputTransientState>({
   const [store, setStore] = createStore<PromptInputTransientState>({
     popover: null,
     popover: null,
+    slashMenu: false,
+    slashMenuQuery: "",
     historyIndex: -1,
     historyIndex: -1,
     savedPrompt: null,
     savedPrompt: null,
     placeholder,
     placeholder,
     draggingType: null,
     draggingType: null,
     mode: "normal",
     mode: "normal",
     applyingHistory: false,
     applyingHistory: false,
-    variantOpen: false,
   })
   })
 
 
   createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
   createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))

+ 36 - 6
packages/app/src/components/prompt-project-selector.tsx

@@ -1,4 +1,13 @@
-import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
+import {
+  createEffect,
+  createSignal,
+  For,
+  onCleanup,
+  Show,
+  splitProps,
+  type Accessor,
+  type ComponentProps,
+} from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -46,7 +55,7 @@ export function createPromptProjectController(input: {
   const [store, setStore] = createStore({ open: false, search: "", active: "" })
   const [store, setStore] = createStore({ open: false, search: "", active: "" })
   let searchRef: HTMLInputElement | undefined
   let searchRef: HTMLInputElement | undefined
 
 
-  const selected = () => {
+  const current = () => {
     const key = pathKey(input.controls().directory)
     const key = pathKey(input.controls().directory)
     return input
     return input
       .controls()
       .controls()
@@ -56,6 +65,7 @@ export function createPromptProjectController(input: {
           (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
           (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
       )
       )
   }
   }
+  const selected = () => current() ?? input.controls().available[0]
   const projects = () => {
   const projects = () => {
     const search = store.search.trim().toLowerCase()
     const search = store.search.trim().toLowerCase()
     if (!search) return input.controls().available
     if (!search) return input.controls().available
@@ -91,8 +101,8 @@ export function createPromptProjectController(input: {
   }
   }
   const select = (project: PromptProject) => {
   const select = (project: PromptProject) => {
     if (
     if (
-      pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
-      project.server?.key !== selected()?.server?.key
+      pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
+      project.server?.key !== current()?.server?.key
     ) {
     ) {
       input.controls().select(project.worktree, project.server?.key)
       input.controls().select(project.worktree, project.server?.key)
     }
     }
@@ -115,6 +125,7 @@ export function createPromptProjectController(input: {
 
 
   return {
   return {
     selected,
     selected,
+    empty: () => input.controls().available.length === 0,
     projects,
     projects,
     servers,
     servers,
     projectKey,
     projectKey,
@@ -184,9 +195,28 @@ export function PromptProjectSelector(props: {
   controller: PromptProjectController
   controller: PromptProjectController
   placement?: "bottom" | "bottom-start"
   placement?: "bottom" | "bottom-start"
 }) {
 }) {
+  const [triggerReady, setTriggerReady] = createSignal(false)
   let contentRef: HTMLDivElement | undefined
   let contentRef: HTMLDivElement | undefined
+  let triggerFrame: number | undefined
   let restoreTrigger = true
   let restoreTrigger = true
 
 
+  // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
+  const setTriggerRef = (element: HTMLButtonElement) => {
+    const ready = () => {
+      if (!element.isConnected) {
+        triggerFrame = requestAnimationFrame(ready)
+        return
+      }
+      triggerFrame = undefined
+      setTriggerReady(true)
+    }
+    ready()
+  }
+
+  onCleanup(() => {
+    if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
+  })
+
   const activeItem = () =>
   const activeItem = () =>
     props.controller.active()
     props.controller.active()
       ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
       ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@@ -257,13 +287,13 @@ export function PromptProjectSelector(props: {
 
 
   return (
   return (
     <DropdownMenu
     <DropdownMenu
-      open={props.controller.open()}
+      open={triggerReady() && props.controller.open()}
       placement={props.placement ?? "bottom"}
       placement={props.placement ?? "bottom"}
       gutter={4}
       gutter={4}
       modal={false}
       modal={false}
       onOpenChange={(open) => props.controller.setOpen(open)}
       onOpenChange={(open) => props.controller.setOpen(open)}
     >
     >
-      <DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
+      <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
       <DropdownMenu.Portal>
       <DropdownMenu.Portal>
         <DropdownMenu.Content
         <DropdownMenu.Content
           ref={contentRef}
           ref={contentRef}

+ 29 - 9
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"
@@ -92,17 +93,36 @@ export function PromptWorkspaceSelector(props: {
           </MenuV2.Content>
           </MenuV2.Content>
         </MenuV2.Portal>
         </MenuV2.Portal>
       </MenuV2>
       </MenuV2>
-      <Show when={props.branch}>
-        {(branch) => (
-          <>
-            <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
+      <PromptGitStatus branch={props.branch} />
+    </>
+  )
+}
+
+export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
+  const language = useLanguage()
+  const label = () => {
+    if (props.noGit) return language.t("session.new.git.none")
+    return props.branch
+  }
+
+  return (
+    <Show when={label()}>
+      {(value) => (
+        <>
+          <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
+          <TooltipV2
+            placement="top"
+            value={value()}
+            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]">
             <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" />
               <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
-              <span class="min-w-0 truncate">{branch()}</span>
+              <span class="min-w-0 truncate">{value()}</span>
             </div>
             </div>
-          </>
-        )}
-      </Show>
-    </>
+          </TooltipV2>
+        </>
+      )}
+    </Show>
   )
   )
 }
 }

+ 7 - 3
packages/app/src/components/session-context-usage.tsx

@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { createMediaQuery } from "@solid-primitives/media"
 
 
 import { useFile } from "@/context/file"
 import { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
@@ -11,9 +12,10 @@ import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
-import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
+import { getSessionContext } from "@/components/session/session-context-metrics"
 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"
+import { useSettings } from "@/context/settings"
 
 
 interface SessionContextUsageProps {
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
   variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const layout = useLayout()
   const layout = useLayout()
   const language = useLanguage()
   const language = useLanguage()
   const sdk = useSDK()
   const sdk = useSDK()
+  const settings = useSettings()
   const providers = useProviders(() => sdk().directory)
   const providers = useProviders(() => sdk().directory)
   const { params, tabs, view } = useSessionLayout()
   const { params, tabs, view } = useSessionLayout()
+  const isDesktop = createMediaQuery("(min-width: 768px)")
 
 
   const variant = createMemo(() => props.variant ?? "button")
   const variant = createMemo(() => props.variant ?? "button")
   const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
   const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
     tabs,
     tabs,
     pathFromTab: file.pathFromTab,
     pathFromTab: file.pathFromTab,
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+    fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
   })
   })
   const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
   const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -69,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   )
   )
 
 
   const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
   const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
-  const tokens = createMemo(() => info()?.tokens)
   const cost = createMemo(() => {
   const cost = createMemo(() => {
     return usd().format(info()?.cost ?? 0)
     return usd().format(info()?.cost ?? 0)
   })
   })
@@ -127,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
       <ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
       <ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
       <ContextTooltipRow
       <ContextTooltipRow
         name={language.t("context.usage.tokens")}
         name={language.t("context.usage.tokens")}
-        value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
+        value={context()?.total.toLocaleString(language.intl()) ?? "0"}
       />
       />
     </div>
     </div>
   )
   )

+ 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,
+  }
+}

+ 5 - 14
packages/app/src/components/session/session-context-metrics.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import type { Message } from "@opencode-ai/sdk/v2/client"
 import type { Message } from "@opencode-ai/sdk/v2/client"
-import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
+import { getSessionContext } from "./session-context-metrics"
 
 
 const assistant = (
 const assistant = (
   id: string,
   id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
 }
 }
 
 
 describe("getSessionContext", () => {
 describe("getSessionContext", () => {
-  test("computes usage from latest assistant with tokens", () => {
+  test("computes token totals and usage from latest assistant with tokens", () => {
     const messages = [
     const messages = [
       user("u1"),
       user("u1"),
-      assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
+      assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
       assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
       assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
     ]
     ]
     const providers = [
     const providers = [
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
     const ctx = getSessionContext(messages, providers)
     const ctx = getSessionContext(messages, providers)
 
 
     expect(ctx?.message.id).toBe("a2")
     expect(ctx?.message.id).toBe("a2")
+    expect(ctx?.total).toBe(500)
+    expect(ctx?.input).toBe(300)
     expect(ctx?.usage).toBe(50)
     expect(ctx?.usage).toBe(50)
     expect(ctx?.providerLabel).toBe("OpenAI")
     expect(ctx?.providerLabel).toBe("OpenAI")
     expect(ctx?.modelLabel).toBe("GPT-4.1")
     expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
 
 
     expect(ctx).toBeUndefined()
     expect(ctx).toBeUndefined()
   })
   })
-
-  test("computes stored session token totals", () => {
-    expect(
-      getSessionTokenTotal({
-        input: 10,
-        output: 20,
-        reasoning: 30,
-        cache: { read: 40, write: 50 },
-      }),
-    ).toBe(150)
-  })
 })
 })

+ 3 - 6
packages/app/src/components/session/session-context-metrics.ts

@@ -1,4 +1,4 @@
-import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
+import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
 
 
 type Provider = {
 type Provider = {
   id: string
   id: string
@@ -21,6 +21,7 @@ type Context = {
   modelLabel: string
   modelLabel: string
   limit: number | undefined
   limit: number | undefined
   input: number
   input: number
+  total: number
   usage: number | null
   usage: number | null
 }
 }
 
 
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
     modelLabel: model?.name ?? message.modelID,
     modelLabel: model?.name ?? message.modelID,
     limit,
     limit,
     input: message.tokens.input,
     input: message.tokens.input,
+    total,
     usage: limit ? Math.round((total / limit) * 100) : null,
     usage: limit ? Math.round((total / limit) * 100) : null,
   }
   }
 }
 }
@@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
 export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
 export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
   return build(messages, providers)
   return build(messages, providers)
 }
 }
-
-export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
-  if (!tokens) return undefined
-  return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
-}

+ 7 - 7
packages/app/src/components/session/session-context-tab.tsx

@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
+import { getSessionContext } from "./session-context-metrics"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
 import { createSessionContextFormatter } from "./session-context-format"
 import { createSessionContextFormatter } from "./session-context-format"
 
 
@@ -135,7 +135,6 @@ export function SessionContextTab() {
   )
   )
 
 
   const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
   const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
-  const tokens = createMemo(() => info()?.tokens)
   const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
   const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
 
 
   const cost = createMemo(() => {
   const cost = createMemo(() => {
@@ -204,14 +203,15 @@ export function SessionContextTab() {
     { label: "context.stats.provider", value: providerLabel },
     { label: "context.stats.provider", value: providerLabel },
     { label: "context.stats.model", value: modelLabel },
     { label: "context.stats.model", value: modelLabel },
     { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
     { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
-    { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
+    { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
     { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
     { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
-    { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
-    { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
-    { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
+    { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
+    { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
+    { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
     {
     {
       label: "context.stats.cacheTokens",
       label: "context.stats.cacheTokens",
-      value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
+      value: () =>
+        `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
     },
     },
     { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
     { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
     { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
     { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },

+ 4 - 7
packages/app/src/components/session/session-header.tsx

@@ -24,6 +24,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { messageAgentColor } from "@/utils/agent"
 import { messageAgentColor } from "@/utils/agent"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
+import { fileManagerApp } from "@/utils/file-manager"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
 import { StatusPopover, StatusPopoverV2 } from "../status-popover"
 import { StatusPopover, StatusPopoverV2 } from "../status-popover"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -31,6 +32,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { reviewTooltipKeybind } from "../command-tooltip-keybind"
 import { reviewTooltipKeybind } from "../command-tooltip-keybind"
+import { useTitlebarRightMount } from "../titlebar"
 
 
 const OPEN_APPS = [
 const OPEN_APPS = [
   "vscode",
   "vscode",
@@ -174,11 +176,7 @@ export function SessionHeader() {
     return LINUX_APPS
     return LINUX_APPS
   })
   })
 
 
-  const fileManager = createMemo(() => {
-    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 }
-  })
+  const fileManager = createMemo(() => fileManagerApp(os()))
 
 
   createEffect(() => {
   createEffect(() => {
     if (platform.platform !== "desktop") return
     if (platform.platform !== "desktop") return
@@ -284,10 +282,9 @@ export function SessionHeader() {
   }
   }
 
 
   const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
   const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
-  const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
+  const rightMount = useTitlebarRightMount()
   onMount(() => {
   onMount(() => {
     setCenterMount(document.getElementById("opencode-titlebar-center"))
     setCenterMount(document.getElementById("opencode-titlebar-center"))
-    setRightMount(document.getElementById("opencode-titlebar-right"))
   })
   })
 
 
   return (
   return (

Some files were not shown because too many files changed in this diff