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

chore: merge dev into sidebar hydration

LukeParkerDev 3 недель назад
Родитель
Сommit
afa57bb862
100 измененных файлов с 8206 добавлено и 2380 удалено
  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
+arvsrn
 Brendonovich
 fwang
 Hona
@@ -7,11 +8,14 @@ jayair
 jlongster
 kitlangton
 kommander
+ludvigrask
 MrMushrooooom
 nexxeln
 R44VC0RP
 rekram1-node
 thdxr
 simonklee
+Slickstef11
+usrnk1
 vimtor
-starptech
+StarpTech

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

@@ -1,6 +1,6 @@
 ---
 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.

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

@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
 const TEAM = {
   tui: ["kommander", "simonklee"],
   desktop_web: ["Hona", "Brendonovich"],
-  core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
+  core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
   inference: ["fwang", "MrMushrooooom", "starptech"],
   windows: ["Hona"],
 } 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": {
       "name": "@opencode-ai/app",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
+        "@corvu/drawer": "catalog:",
         "@dnd-kit/abstract": "0.5.0",
         "@dnd-kit/dom": "0.5.0",
         "@dnd-kit/helpers": "0.5.0",
@@ -72,6 +73,7 @@
         "shiki": "catalog:",
         "solid-js": "catalog:",
         "solid-list": "catalog:",
+        "solid-presence": "0.2.0",
         "tailwindcss": "catalog:",
       },
       "devDependencies": {
@@ -93,7 +95,7 @@
     },
     "packages/cli": {
       "name": "@opencode-ai/cli",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "bin": {
         "lildax": "./bin/lildax.cjs",
       },
@@ -141,7 +143,7 @@
     },
     "packages/codemode": {
       "name": "@opencode-ai/codemode",
-      "version": "0.0.1",
+      "version": "1.18.3",
       "dependencies": {
         "acorn": "8.15.0",
         "effect": "catalog:",
@@ -155,7 +157,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -191,7 +193,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -218,7 +220,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.82",
         "@ai-sdk/openai": "3.0.48",
@@ -240,7 +242,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -264,7 +266,7 @@
     },
     "packages/console/support": {
       "name": "@opencode-ai/console-support",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@opencode-ai/console-core": "workspace:*",
@@ -284,7 +286,7 @@
     },
     "packages/core": {
       "name": "@opencode-ai/core",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -292,7 +294,7 @@
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/amazon-bedrock": "4.0.112",
         "@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/cohere": "3.0.27",
         "@ai-sdk/deepinfra": "2.0.41",
@@ -301,14 +303,14 @@
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/groq": "3.0.31",
         "@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/perplexity": "3.0.26",
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/provider-utils": "4.0.23",
         "@ai-sdk/togetherai": "2.0.41",
         "@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",
         "@effect/opentelemetry": "catalog:",
         "@effect/platform-node": "catalog:",
@@ -336,7 +338,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.10.0",
+        "gitlab-ai-provider": "6.11.1",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -378,7 +380,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@zip.js/zip.js": "2.7.62",
         "effect": "catalog:",
@@ -432,7 +434,7 @@
     },
     "packages/effect-drizzle-sqlite": {
       "name": "@opencode-ai/effect-drizzle-sqlite",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
@@ -446,7 +448,7 @@
     },
     "packages/effect-sqlite-node": {
       "name": "@opencode-ai/effect-sqlite-node",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "effect": "catalog:",
       },
@@ -458,7 +460,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@hono/standard-validator": "catalog:",
         "@opencode-ai/core": "workspace:*",
@@ -490,7 +492,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -506,7 +508,7 @@
     },
     "packages/http-recorder": {
       "name": "@opencode-ai/http-recorder",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@effect/platform-node": "4.0.0-beta.83",
         "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -537,7 +539,7 @@
     },
     "packages/llm": {
       "name": "@opencode-ai/llm",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@opencode-ai/schema": "workspace:*",
         "@smithy/eventstream-codec": "4.2.14",
@@ -556,7 +558,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -567,7 +569,7 @@
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/amazon-bedrock": "4.0.112",
         "@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/cohere": "3.0.27",
         "@ai-sdk/deepinfra": "2.0.41",
@@ -576,13 +578,13 @@
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/groq": "3.0.31",
         "@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/perplexity": "3.0.26",
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/togetherai": "2.0.41",
         "@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",
         "@clack/prompts": "1.0.0-alpha.1",
         "@effect/opentelemetry": "catalog:",
@@ -629,7 +631,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.10.0",
+        "gitlab-ai-provider": "6.11.1",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -687,7 +689,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@ai-sdk/provider": "3.0.8",
         "@opencode-ai/sdk": "workspace:*",
@@ -704,9 +706,9 @@
         "typescript": "catalog:",
       },
       "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": [
         "@opentui/core",
@@ -763,7 +765,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -778,7 +780,7 @@
     },
     "packages/server": {
       "name": "@opencode-ai/server",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/protocol": "workspace:*",
@@ -793,7 +795,7 @@
     },
     "packages/session-ui": {
       "name": "@opencode-ai/session-ui",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/core": "workspace:*",
@@ -837,7 +839,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -850,9 +852,10 @@
     },
     "packages/stats/app": {
       "name": "@opencode-ai/stats-app",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@ibm/plex": "6.4.1",
+        "@kobalte/core": "catalog:",
         "@opencode-ai/stats-core": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@solidjs/meta": "catalog:",
@@ -883,7 +886,7 @@
     },
     "packages/stats/core": {
       "name": "@opencode-ai/stats-core",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@aws-sdk/client-athena": "3.933.0",
         "@planetscale/database": "1.19.0",
@@ -902,7 +905,7 @@
     },
     "packages/stats/server": {
       "name": "@opencode-ai/stats-server",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@aws-sdk/client-firehose": "3.933.0",
         "@effect/platform-node": "catalog:",
@@ -934,6 +937,7 @@
         "@types/node": "catalog:",
         "@types/react": "18.0.25",
         "react": "18.2.0",
+        "react-dom": "18.2.0",
         "solid-js": "catalog:",
         "storybook": "^10.2.13",
         "storybook-solidjs-vite": "^10.0.9",
@@ -943,7 +947,7 @@
     },
     "packages/tui": {
       "name": "@opencode-ai/tui",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
@@ -970,7 +974,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@pierre/diffs": "catalog:",
@@ -1021,7 +1025,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.17.13",
+      "version": "1.18.3",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -1064,17 +1068,16 @@
   ],
   "patchedDependencies": {
     "@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",
     "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",
     "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",
     "@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",
     "@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",
   },
   "overrides": {
@@ -1086,6 +1089,7 @@
   },
   "catalog": {
     "@cloudflare/workers-types": "4.20251008.0",
+    "@corvu/drawer": "0.2.4",
     "@effect/opentelemetry": "4.0.0-beta.83",
     "@effect/platform-node": "4.0.0-beta.83",
     "@effect/sql-sqlite-bun": "4.0.0-beta.83",
@@ -1096,9 +1100,9 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@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",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
@@ -1109,7 +1113,7 @@
     "@solidjs/router": "0.15.4",
     "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
     "@tailwindcss/vite": "4.1.11",
-    "@tanstack/solid-virtual": "3.13.28",
+    "@tanstack/solid-virtual": "3.13.32",
     "@tsconfig/bun": "1.0.9",
     "@tsconfig/node22": "22.0.2",
     "@types/bun": "1.3.13",
@@ -1129,7 +1133,7 @@
     "hono": "4.10.7",
     "hono-openapi": "1.1.2",
     "luxon": "3.6.1",
-    "marked": "17.0.1",
+    "marked": "17.0.6",
     "marked-shiki": "1.2.1",
     "opentui-spinner": "0.0.7",
     "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/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=="],
 
@@ -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/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=="],
 
@@ -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/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=="],
 
@@ -1467,6 +1471,10 @@
 
     "@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=="],
 
     "@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=="],
 
-    "@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=="],
 
@@ -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/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/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-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=="],
 
@@ -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=="],
 
-    "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=="],
 
@@ -3841,7 +3851,7 @@
 
     "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=="],
 
@@ -4303,7 +4313,7 @@
 
     "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=="],
 
@@ -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=="],
 
+    "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-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=="],
 
@@ -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-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=="],
 
     "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=="],
 
-    "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-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/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=="],
 
@@ -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/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=="],
 
@@ -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/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=="],
 
@@ -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=="],
 
+    "@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=="],
 
     "@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=="],
 
-    "@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=="],
 
@@ -6027,6 +6051,8 @@
 
     "@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=="],
 
     "@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/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/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=="],
 
     "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/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=="],
 
@@ -6443,6 +6475,8 @@
 
     "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=="],
 
     "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/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/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=="],
 
+    "@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/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/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=="],
 
     "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/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=="],
 
     "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=="],
 
+    "@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=="],
 
     "@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/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=="],
 
     "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/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-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: {
     enforceWorkgroupConfiguration: 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: {
       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 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", {
   hostname: domain,
   regionKey: "us",

+ 3 - 1
infra/stats.ts

@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
   cluster: lakeCluster,
   architecture: "arm64",
   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: {
     context: ".",
     dockerfile: "packages/stats/server/Dockerfile",

+ 66 - 33
nix/desktop.nix

@@ -8,6 +8,8 @@
   makeWrapper,
   writableTmpDirAsHomeHook,
   autoPatchelfHook,
+  copyDesktopItems,
+  makeDesktopItem,
   opencode,
 }:
 let
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
     nodejs
     makeWrapper
     writableTmpDirAsHomeHook
-  ] ++ lib.optionals stdenv.hostPlatform.isLinux [
+  ]
+  ++ lib.optionals stdenv.hostPlatform.isLinux [
     autoPatchelfHook
-  ] ++ lib.optionals stdenv.hostPlatform.isDarwin [
+    copyDesktopItems
+  ]
+  ++ lib.optionals stdenv.hostPlatform.isDarwin [
     # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
     darwin.autoSignDarwinBinariesHook
   ];
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
     (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 // {
     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 = ''
     cp -r "${electron.dist}" $HOME/.electron-dist
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
     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 = [
     "libc.musl-x86_64.so.1"

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "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",
     "random": "echo 'Random script'",
     "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"
   },
   "workspaces": {
@@ -39,13 +40,14 @@
       "@octokit/rest": "22.0.0",
       "@hono/standard-validator": "0.2.0",
       "@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",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
+      "@corvu/drawer": "0.2.4",
       "@types/luxon": "3.7.1",
       "@types/node": "24.12.2",
       "@types/semver": "7.7.1",
@@ -68,7 +70,7 @@
       "hono-openapi": "1.1.2",
       "fuzzysort": "3.1.0",
       "luxon": "3.6.1",
-      "marked": "17.0.1",
+      "marked": "17.0.6",
       "marked-shiki": "1.2.1",
       "remend": "1.3.0",
       "@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",
     "@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",
-    "@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",
     "pacote@21.5.0": "patches/pacote@21.5.0.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",
     "@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 ?? {})
   if (input.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))
 })
 
+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) => {
   const shellID = "prt_keyboard_01_shell"
   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[]
   hasVisibleRows: boolean
   last: boolean
+  requiredPartVisible?: boolean
+  bottomAnchorRequired?: boolean
   bottomErrorPx?: number
   review?: {
     fileHost: boolean
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
     sample.destination.length > 0 &&
     sample.source.length === 0 &&
     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(
   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 source = new Set(sourceIDs)
     const samples: SessionSwitchSample[] = []
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
             const rect = element.getBoundingClientRect()
             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()
           samples.push({
             observedAtMs,
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
             source: visible.filter((id) => source.has(id)),
             hasVisibleRows,
             last: visible.includes(lastID),
+            requiredPartVisible,
+            bottomAnchorRequired: requireBottomAnchor !== false,
             bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
             review,
           })
         } 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)
       }, 0)
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
             sample.destination.length > 0 &&
             sample.source.length === 0 &&
             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(
   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
   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) {

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

+ 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 { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { installSseTransport } from "../utils/sse-transport"
 import { expectSessionTitle } from "../utils/waits"
 
 const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
   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(
   page: Page,
   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",
       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]") })
     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 { expect, test } from "@playwright/test"
+import { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
 import { expectSessionTitle } from "../utils/waits"
 
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
 const projectID = "proj_terminal_composer_focus"
 const sessionID = "ses_terminal_composer_focus"
 const ptyID = "pty_terminal_composer_focus"
+const newPtyID = "pty_terminal_composer_focus_new"
 
 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, {
     directory,
     project: {
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
   await page.addInitScript(() => {
     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 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).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 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 mockOpenCodeServer(page, {
     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 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 page.setViewportSize({ width: 1200, height: 700 })
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
 
   await page.keyboard.press("Control+Backquote")
+  await expect(panel).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) {
-  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.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/session-timeline-context-resize.spec.ts",
     "./utils/**/*.ts"

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

@@ -1,7 +1,7 @@
 import type { Page, Route } from "@playwright/test"
 
 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 {
   provider: unknown
@@ -11,7 +11,10 @@ export interface MockServerConfig {
   pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
   vcsDiff?: unknown[]
   messageDelay?: number
+  beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<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[]
   eventRetry?: number
   todos?: (sessionID: string) => unknown[]
@@ -19,6 +22,7 @@ export interface MockServerConfig {
   questions?: unknown[] | (() => unknown[])
   fileList?: (path: string) => unknown | Promise<unknown>
   fileContent?: (path: string) => unknown | Promise<unknown>
+  findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
   sessionStatus?: unknown
 }
 
@@ -52,6 +56,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     const path = url.pathname
     if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
     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")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     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") ?? ""))
     if (path === "/file/content" && config.fileContent)
       return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
+    if (path === "/find/file" && config.findFiles)
+      return json(
+        route,
+        await config.findFiles({
+          query: url.searchParams.get("query") ?? "",
+          dirs: url.searchParams.get("dirs") ?? undefined,
+          limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
+        }),
+      )
+    if (path === "/api/reference")
+      return json(route, {
+        location: {
+          directory: config.directory,
+          project: { id: (config.project as { id?: string }).id, directory: config.directory },
+        },
+        data: [],
+      })
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
     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 ?? {})
     }
 
+    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$/)
     if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
     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
       if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
       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 pageData = config.pageMessages(messagesMatch[1], limit, before)
       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) {
   return route.fulfill({
     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 url = new URL(request.url)
         if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
-          return originalFetch(input, init)
+          return originalFetch(request)
 
         const id = ++nextConnectionID
         const record = {

+ 3 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.17.13",
+  "version": "1.18.3",
   "description": "",
   "type": "module",
   "exports": {
@@ -47,6 +47,7 @@
     "vite-plugin-solid": "catalog:"
   },
   "dependencies": {
+    "@corvu/drawer": "catalog:",
     "@dnd-kit/abstract": "0.5.0",
     "@dnd-kit/dom": "0.5.0",
     "@dnd-kit/helpers": "0.5.0",
@@ -88,6 +89,7 @@
     "shiki": "catalog:",
     "solid-js": "catalog:",
     "solid-list": "catalog:",
+    "solid-presence": "0.2.0",
     "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 { ThemeProvider } from "@opencode-ai/ui/theme/context"
 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 { Effect } from "effect"
+import { base64Encode } from "@opencode-ai/core/util/encode"
 import {
   type Component,
   createEffect,
@@ -28,11 +38,12 @@ import {
   Show,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
 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 { HighlightsProvider } from "@/context/highlights"
 import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,9 +63,10 @@ import LegacyLayout from "@/pages/layout"
 import NewLayout from "@/pages/layout-new"
 import { ErrorPage } from "./pages/error"
 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"
 
 const NewSession = lazy(() => import("@/pages/new-session"))
@@ -88,10 +100,14 @@ const SessionRoute = () => {
     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 global = useGlobal()
   const conn = createMemo(() => {
@@ -105,17 +121,49 @@ const TargetSessionRoute = () => {
     // re-resolves reactively instead); both rely on this key for server changes.
     <Show when={requireServerKey(params.serverKey)} keyed>
       <ServerSDKProvider server={conn}>
-        <ServerSyncProvider server={conn}>
-          <TargetSessionRouteContent />
-        </ServerSyncProvider>
+        <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
       </ServerSDKProvider>
     </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
-// 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) {
   return (
     <ServerKey>
@@ -126,16 +174,17 @@ function SelectedServerProviders(props: ParentProps) {
   )
 }
 
-function LegacyServerLayout(props: ParentProps) {
+function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
   return (
     <SelectedServerProviders>
-      <LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
+      <LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
     </SelectedServerProviders>
   )
 }
 
 function DraftRoute() {
   const [search] = useSearchParams<{ draftId?: string }>()
+  const settings = useSettings()
   const tabs = useTabs()
   return (
     <Show when={tabs.ready()}>
@@ -144,7 +193,14 @@ function DraftRoute() {
         keyed
         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>
   )
@@ -160,7 +216,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
     <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
       <ServerSDKProvider server={conn}>
         <ServerSyncProvider server={conn}>
-          <DraftServerScopedProviders directory={directory}>
+          <ModelsProvider directory={directory}>
             <SDKProvider directory={directory}>
               <DirectoryDataProvider directory={directory} server={serverKey}>
                 <DraftProviders>
@@ -168,7 +224,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
                 </DraftProviders>
               </DirectoryDataProvider>
             </SDKProvider>
-          </DraftServerScopedProviders>
+          </ModelsProvider>
         </ServerSyncProvider>
       </ServerSDKProvider>
     </Show>
@@ -186,7 +242,7 @@ declare global {
       deepLinks?: string[]
     }
     api?: {
-      setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
+      setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
       exportDebugLogs?: () => Promise<string>
     }
   }
@@ -262,45 +318,36 @@ function DesktopCommands() {
 // Server-scoped providers shared by the legacy shell and the top-level new shell.
 type ServerScopedShellProps = ParentProps<{
   directory?: () => string | undefined
-  sessionID?: () => string | undefined
+  serverScoped?: JSX.Element
 }>
 
 function ServerScopedProviders(props: ServerScopedShellProps) {
   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) {
   return (
-    <ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
+    <ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
       <LegacyLayout>{props.children}</LegacyLayout>
     </ServerScopedProviders>
   )
 }
 
-function NewAppLayout(props: ParentProps) {
+function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
   return (
     <SelectedServerProviders>
-      <ServerScopedProviders>
+      <ServerScopedProviders serverScoped={props.serverScoped}>
         <NewLayout>{props.children}</NewLayout>
       </ServerScopedProviders>
     </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.
 // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
 function DraftProviders(props: ParentProps) {
@@ -318,8 +365,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
     <MetaProvider>
       <Font />
       <ThemeProvider
-        onThemeApplied={(_, mode) => {
-          void window.api?.setTitlebar?.({ mode })
+        onThemeApplied={(_, mode, scheme) => {
+          void window.api?.setTitlebar?.({ mode, scheme })
         }}
       >
         <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 checkServerHealth = useCheckServerHealth()
 
@@ -376,34 +423,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
   const checking = createMemo(
     () => 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 (
-    <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" />
         </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>
+    </>
   )
 }
 
@@ -470,6 +528,8 @@ export function AppInterface(props: {
   servers?: Array<ServerConnection.Any>
   router?: Component<BaseRouterProps>
   disableHealthCheck?: boolean
+  startup?: Promise<void>
+  serverScoped?: JSX.Element
 }) {
   // 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
@@ -491,23 +551,25 @@ export function AppInterface(props: {
     >
       <GlobalProvider>
         <SettingsProvider>
-          <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
+          <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
             <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
               <Dynamic
                 component={props.router ?? Router}
                 root={(routerProps) => (
                   <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>
                 )}
               >
-                <Routes />
+                <Routes serverScoped={props.serverScoped} />
               </Dynamic>
             </Show>
           </ConnectionGate>
@@ -517,13 +579,24 @@ export function AppInterface(props: {
   )
 }
 
-function Routes() {
+function Routes(props: { serverScoped?: JSX.Element }) {
   const settings = useSettings()
 
   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="/" component={() => <Navigate href="session" />} />
           <Route path="/session/:id?" component={SessionRoute} />
@@ -531,15 +604,15 @@ function Routes() {
       </Route>
       <Show when={settings.general.newLayoutDesigns()}>
         <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>
       <Route path="/new-session" component={DraftRoute} />
-      <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
     </>
   )
 }
 
-function LegacyTargetSessionRoute() {
+function NewLayoutLegacySessionRedirect() {
   const server = useServer()
   const tabs = useTabs()
   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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
 
 type Mem = Performance & {
   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 } = {}) {
   const language = useLanguage()
+  const platform = usePlatform()
   const location = useLocation()
   const routing = useIsRouting()
   const [state, setState] = createStore({
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
     delay: undefined as number | undefined,
     fps: undefined as number | undefined,
     gap: undefined as number | undefined,
+    focus: false,
     heap: {
       limit: 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 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 start = 0
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
           bad={bad(heap(), 0.8)}
           dim={state.heap.used === undefined}
           inline={props.inline}
-          wide
+          wide={!platform.setForceFocus}
         />
+        {platform.setForceFocus && (
+          <FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
+        )}
       </div>
     </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 { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tag } from "@opencode-ai/ui/tag"
 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 { 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 { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 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 serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
   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 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 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({
-    methodIndex: undefined as undefined | number,
+    methodIndex: directMethod as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     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,
   })
 
@@ -134,6 +512,16 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
     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 {
     if (value && typeof value === "object" && "data" in value) {
       const data = (value as { data?: { message?: unknown } }).data
@@ -364,22 +752,47 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
 
   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" })
       return
     }
-    all()
+    props.onBack()
   }
 
+  props.setBack(goBack)
+
   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 (
       <>
         <div class="text-14-regular text-text-base">
@@ -413,11 +826,18 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
 
   function ApiAuthView() {
+    let apiKey: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
       value: "",
       error: undefined as string | undefined,
     })
 
+    onMount(() => {
+      if (!newLayout()) return
+      apiKey?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
 
@@ -442,6 +862,58 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
       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 (
       <div class="flex flex-col gap-6">
         <Switch>
@@ -466,7 +938,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
         </Switch>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={apiKey}
             type="text"
             label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
             placeholder={language.t("provider.connect.apiKey.placeholder")}
@@ -485,11 +958,18 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
 
   function OAuthCodeView() {
+    let codeInput: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
       value: "",
       error: undefined as string | undefined,
     })
 
+    onMount(() => {
+      if (!newLayout()) return
+      codeInput?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
       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")))
     }
 
+    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 (
       <div class="flex flex-col gap-6">
         <div class="text-14-regular text-text-base">
@@ -527,7 +1047,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
         </div>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={codeInput}
             type="text"
             label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
             placeholder={language.t("provider.connect.oauth.code.placeholder")}
@@ -599,79 +1120,80 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
   }
 
   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 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>
-              </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>
-              </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>
-              </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>
-    </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 { TextField } from "@opencode-ai/ui/text-field"
 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 { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
-import { DialogSelectProvider } from "./dialog-select-provider"
 
 type Props = {
-  back?: "providers" | "close"
-  directory?: Accessor<string | undefined>
+  onBack: () => void
 }
 
 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 serverSync = useServerSync()
   const serverSDK = useServerSDK()
@@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
     err: {},
   })
 
-  const goBack = () => {
-    if (props.back === "close") {
-      dialog.close()
-      return
-    }
-    dialog.show(() => <DialogSelectProvider directory={props.directory} />)
-  }
-
   const addModel = () => {
     setForm(
       "models",
@@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) {
   }
 
   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>
-              )}
-            </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>
-              )}
-            </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>
-        </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 { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { TextField } from "@opencode-ai/ui/text-field"
-import { useMutation } from "@tanstack/solid-query"
 import { Icon } from "@opencode-ai/ui/icon"
-import { createMemo, For, Show } from "solid-js"
-import { createStore } from "solid-js/store"
+import { For, Show } from "solid-js"
 import { type LocalProject, getAvatarColors } from "@/context/layout"
-import { getFilename } from "@opencode-ai/core/util/path"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { useLanguage } from "@/context/language"
 import { getProjectAvatarSource } from "@/pages/layout/helpers"
 import { ServerConnection } from "@/context/server"
-import { useGlobal } from "@/context/global"
+import { createEditProjectModel } from "./edit-project"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 
 export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
-  const dialog = useDialog()
-  const global = useGlobal()
   const language = useLanguage()
-  const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
-  const serverSDK = () => serverCtx().sdk
-  const serverSync = () => serverCtx().sync
-
-  const folderName = createMemo(() => getFilename(props.project.worktree))
-  const defaultName = createMemo(() => props.project.name || folderName())
-
-  const [store, setStore] = createStore({
-    name: defaultName(),
-    color: props.project.icon?.color,
-    iconOverride: props.project.icon?.override,
-    startup: props.project.commands?.start ?? "",
-    dragOver: false,
-    iconHover: false,
-  })
-
-  let iconInput: HTMLInputElement | undefined
-
-  function handleFileSelect(file: File) {
-    if (!file.type.startsWith("image/")) return
-    const reader = new FileReader()
-    reader.onload = (e) => {
-      setStore("iconOverride", e.target?.result as string)
-      setStore("iconHover", false)
-    }
-    reader.readAsDataURL(file)
-  }
-
-  function handleDrop(e: DragEvent) {
-    e.preventDefault()
-    setStore("dragOver", false)
-    const file = e.dataTransfer?.files[0]
-    if (file) handleFileSelect(file)
-  }
-
-  function handleDragOver(e: DragEvent) {
-    e.preventDefault()
-    setStore("dragOver", true)
-  }
-
-  function handleDragLeave() {
-    setStore("dragOver", false)
-  }
-
-  function handleInputChange(e: Event) {
-    const input = e.target as HTMLInputElement
-    const file = input.files?.[0]
-    if (file) handleFileSelect(file)
-  }
-
-  function clearIcon() {
-    setStore("iconOverride", "")
-  }
-
-  const saveMutation = useMutation(() => ({
-    mutationFn: async () => {
-      const name = store.name.trim() === folderName() ? "" : store.name.trim()
-      const start = store.startup.trim()
-
-      if (props.project.id && props.project.id !== "global") {
-        await serverSDK().client.project.update({
-          projectID: props.project.id,
-          directory: props.project.worktree,
-          name,
-          icon: { color: store.color || "", override: store.iconOverride || "" },
-          commands: { start },
-        })
-        serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
-        dialog.close()
-        return
-      }
-
-      serverSync().project.meta(props.project.worktree, {
-        name,
-        icon: { color: store.color || undefined, override: store.iconOverride || undefined },
-        commands: { start: start || undefined },
-      })
-      dialog.close()
-    },
-  }))
-
-  function handleSubmit(e: SubmitEvent) {
-    e.preventDefault()
-    if (saveMutation.isPending) return
-    saveMutation.mutate()
-  }
+  const model = createEditProjectModel(props)
 
   return (
     <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
-      <form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
+      <form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
         <div class="flex flex-col gap-4">
           <TextField
             autofocus
             type="text"
             label={language.t("dialog.project.edit.name")}
-            placeholder={folderName()}
-            value={store.name}
-            onChange={(v) => setStore("name", v)}
+            placeholder={model.folderName()}
+            value={model.store.name}
+            onChange={(v) => model.setStore("name", v)}
           />
 
           <div class="flex flex-col gap-2">
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             <div class="flex gap-3 items-start">
               <div
                 class="relative"
-                onMouseEnter={() => setStore("iconHover", true)}
-                onMouseLeave={() => setStore("iconHover", false)}
+                onMouseEnter={() => model.setStore("iconHover", true)}
+                onMouseLeave={() => model.setStore("iconHover", false)}
               >
                 <div
                   class="relative size-16 rounded-md transition-colors cursor-pointer"
                   classList={{
-                    "border-text-interactive-base bg-surface-info-base/20": store.dragOver,
-                    "border-border-base hover:border-border-strong": !store.dragOver,
-                    "overflow-hidden": !!store.iconOverride,
-                  }}
-                  onDrop={handleDrop}
-                  onDragOver={handleDragOver}
-                  onDragLeave={handleDragLeave}
-                  onClick={() => {
-                    if (store.iconOverride && store.iconHover) {
-                      clearIcon()
-                    } else {
-                      iconInput?.click()
-                    }
+                    "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
+                    "border-border-base hover:border-border-strong": !model.store.dragOver,
+                    "overflow-hidden": !!model.store.iconOverride,
                   }}
+                  onDrop={model.drop}
+                  onDragOver={model.dragOver}
+                  onDragLeave={model.dragLeave}
+                  onClick={model.iconClick}
                 >
                   <Show
                     when={getProjectAvatarSource(props.project.id, {
-                      color: store.color,
+                      color: model.store.color,
                       url: props.project.icon?.url,
-                      override: store.iconOverride,
+                      override: model.store.iconOverride,
                     })}
                     fallback={
                       <div class="size-full flex items-center justify-center">
                         <Avatar
-                          fallback={store.name || defaultName()}
-                          {...getAvatarColors(store.color)}
+                          fallback={model.store.name || model.defaultName()}
+                          {...getAvatarColors(model.store.color)}
                           class="size-full text-[32px]"
                         />
                       </div>
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                 <div
                   class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
                   classList={{
-                    "opacity-100": store.iconHover && !store.iconOverride,
-                    "opacity-0": !(store.iconHover && !store.iconOverride),
+                    "opacity-100": model.store.iconHover && !model.store.iconOverride,
+                    "opacity-0": !(model.store.iconHover && !model.store.iconOverride),
                   }}
                 >
                   <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                 <div
                   class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
                   classList={{
-                    "opacity-100": store.iconHover && !!store.iconOverride,
-                    "opacity-0": !(store.iconHover && !!store.iconOverride),
+                    "opacity-100": model.store.iconHover && !!model.store.iconOverride,
+                    "opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
                   }}
                 >
                   <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
               <input
                 id="icon-upload"
                 ref={(el) => {
-                  iconInput = el
+                  model.setIconInput(el)
                 }}
                 type="file"
                 accept="image/*"
                 class="hidden"
-                onChange={handleInputChange}
+                onChange={model.inputChange}
               />
               <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
                 <span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             </div>
           </div>
 
-          <Show when={!store.iconOverride}>
+          <Show when={!model.store.iconOverride}>
             <div class="flex flex-col gap-2">
               <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
               <div class="flex gap-1.5">
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
                     <button
                       type="button"
                       aria-label={language.t("dialog.project.edit.color.select", { color })}
-                      aria-pressed={store.color === color}
+                      aria-pressed={model.store.color === color}
                       classList={{
                         "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
                         "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
-                          store.color === color,
+                          model.store.color === color,
                         "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
-                          store.color !== color,
+                          model.store.color !== color,
                       }}
                       onClick={() => {
-                        if (store.color === color && !props.project.icon?.url) return
-                        setStore("color", store.color === color ? undefined : color)
+                        if (model.store.color === color && !props.project.icon?.url) return
+                        model.setStore("color", model.store.color === color ? undefined : color)
                       }}
                     >
                       <Avatar
-                        fallback={store.name || defaultName()}
+                        fallback={model.store.name || model.defaultName()}
                         {...getAvatarColors(color)}
                         class="size-full rounded"
                       />
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
             label={language.t("dialog.project.edit.worktree.startup")}
             description={language.t("dialog.project.edit.worktree.startup.description")}
             placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
-            value={store.startup}
-            onChange={(v) => setStore("startup", v)}
+            value={model.store.startup}
+            onChange={(v) => model.setStore("startup", v)}
             spellcheck={false}
             class="max-h-14 w-full overflow-y-auto font-mono text-xs"
           />
         </div>
 
         <div class="flex justify-end gap-2">
-          <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
+          <Button type="button" variant="ghost" size="large" onClick={model.close}>
             {language.t("common.cancel")}
           </Button>
-          <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
-            {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
+          <Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
+            {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
           </Button>
         </div>
       </form>

+ 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 { useLanguage } from "@/context/language"
 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 { SettingsListV2 } from "./settings-v2/parts/list"
 import { SettingsRowV2 } from "./settings-v2/parts/row"
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
   const directory = () => decode64(local.slug())
 
   const handleConnectProvider = () => {
-    dialog.show(() => <DialogSelectProvider directory={directory} />)
+    void dialog.show(() => <DialogConnectProvider directory={directory} />)
   }
   const providerRank = (id: string) => popularProviders.indexOf(id)
   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 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 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 { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Keybind } from "@opencode-ai/ui/keybind"
 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 { 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 { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { createSessionTabs } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 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(() =>
   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"
 
-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 platform = usePlatform()
-  const settings = useSettings()
-  const layout = useLayout()
-  const file = useFile()
-  const dialog = useDialog()
-  const navigate = useNavigate()
   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 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 query = text.trim()
     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) {
         void pending
@@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       }
 
       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 (
@@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       <List
         class="px-3"
         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,
           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}
         key={(item) => item.id}
         filterKeys={["title", "description", "category"]}
         skipFilter={(item) => item.type === "file"}
         groupBy={grouped() ? (item) => item.category : () => ""}
-        onMove={handleMove}
-        onSelect={handleSelect}
+        onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
+        onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
       >
         {(item) => (
           <Switch
@@ -452,7 +149,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                   </Show>
                 </div>
                 <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>
               </div>
             </Match>
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                 </div>
                 <Show when={item.updated}>
                   <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>
                 </Show>
               </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 language = useLanguage()
 
-  const connect = (provider: string) => {
+  const openProviders = (provider?: string) => {
     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
   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 { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-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 { useLanguage } from "@/context/language"
 import { decode64 } from "@/utils/base64"
@@ -92,6 +93,7 @@ const ModelList: Component<{
           class="w-full"
           placement="right-start"
           gutter={12}
+          openDelay={0}
           value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
         >
           {node}
@@ -155,8 +157,8 @@ export function ModelSelectorPopover(props: {
 
   const handleConnectProvider = () => {
     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()
@@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
                       <MenuV2.RadioGroup value={current()}>
                         <For each={group.items}>
                           {(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>
                       </MenuV2.RadioGroup>
@@ -503,8 +520,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
   const directory = () => decode64(local.slug())
 
   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 { Tabs } from "@opencode-ai/ui/tabs"
 import { Icon } from "@opencode-ai/ui/icon"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { SettingsGeneral } from "./settings-general"
 import { SettingsKeybinds } from "./settings-keybinds"
 import { SettingsProviders } from "./settings-providers"
 import { SettingsModels } from "./settings-models"
 import { SettingsServers } from "./settings-servers"
 
-export const DialogSettings: Component = () => {
+export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
   const language = useLanguage()
   const platform = usePlatform()
+  const dialog = useDialog()
+  const [tab, setTab] = createSignal(props.defaultValue ?? "general")
+
+  const showProviders = () => {
+    void dialog.show(() => <DialogSettings defaultValue="providers" />)
+  }
 
   return (
     <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>
           <div class="flex flex-col justify-between h-full w-full gap-4">
             <div class="flex flex-col gap-3 w-full pt-3">
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
           <SettingsServers />
         </Tabs.Content>
         <Tabs.Content value="providers" class="no-scrollbar">
-          <SettingsProviders />
+          <SettingsProviders onBack={showProviders} />
         </Tabs.Content>
         <Tabs.Content value="models" class="no-scrollbar">
           <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 { Collapsible } from "@opencode-ai/ui/collapsible"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import "@opencode-ai/ui/v2/file-tree-v2.css"
 import {
   createEffect,
   createMemo,
+  createSignal,
   For,
-  Match,
-  on,
   Show,
   splitProps,
-  Switch,
-  untrack,
   type ComponentProps,
   type ParentProps,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import type { FileNode } from "@opencode-ai/sdk/v2"
 import { Icon } from "@opencode-ai/ui/v2/icon"
+import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
+import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
 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"
 
-const MAX_DEPTH = 128
-
-function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
-  const nodes = children(path)
-  if (!current) return nodes
-
-  const parent = (item: string) => {
-    const idx = item.lastIndexOf("/")
-    if (idx === -1) return ""
-    return item.slice(0, idx)
-  }
-
-  const leaf = (item: string) => {
-    const idx = item.lastIndexOf("/")
-    return idx === -1 ? item : item.slice(idx + 1)
-  }
-
-  const out = nodes.filter((node) => {
-    if (node.type === "file") return current.files.has(node.path)
-    return current.dirs.has(node.path)
-  })
-
-  const seen = new Set(out.map((node) => node.path))
-
-  for (const dir of current.dirs) {
-    if (parent(dir) !== path) continue
-    if (seen.has(dir)) continue
-    out.push({
-      name: leaf(dir),
-      path: dir,
-      absolute: dir,
-      type: "directory",
-      ignored: false,
-    })
-    seen.add(dir)
-  }
-
-  for (const item of current.files) {
-    if (parent(item) !== path) continue
-    if (seen.has(item)) continue
-    out.push({
-      name: leaf(item),
-      path: item,
-      absolute: item,
-      type: "file",
-      ignored: false,
-    })
-    seen.add(item)
-  }
-
-  out.sort((a, b) => {
-    if (a.type !== b.type) {
-      return a.type === "directory" ? -1 : 1
-    }
-    return a.name.localeCompare(b.name)
-  })
-
-  return out
-}
-
 const INDENT_STEP = 16
 
 function rowPaddingLeft(level: number, type: FileNode["type"]) {
@@ -105,7 +42,7 @@ function guideLineLeft(level: number) {
 export const kindLabel = (kind: Kind) => {
   if (kind === "add") return "A"
   if (kind === "del") return "D"
-  return ""
+  return "M"
 }
 
 export const kindChange = (kind: Kind) => {
@@ -123,7 +60,6 @@ const FileTreeNodeV2 = (
       active?: string
       draggable: boolean
       kinds?: ReadonlyMap<string, Kind>
-      marks?: Set<string>
       as?: "div" | "button"
     },
 ) => {
@@ -133,18 +69,18 @@ const FileTreeNodeV2 = (
     "active",
     "draggable",
     "kinds",
-    "marks",
     "as",
     "children",
     "class",
     "classList",
   ])
-  const kind = () => visibleKind(local.node, local.kinds, local.marks)
+  const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
 
   return (
     <Dynamic
       component={local.as ?? "div"}
       data-slot="file-tree-v2-row"
+      data-path={local.node.path}
       data-selected={local.node.path === local.active ? "" : undefined}
       data-ignored={local.node.ignored ? "" : undefined}
       classList={{
@@ -177,244 +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: {
-  path: string
   active?: string
-  level?: number
   allowed?: readonly string[]
   kinds?: ReadonlyMap<string, Kind>
   draggable?: boolean
   onFileClick?: (file: FileNode) => void
-
-  _filter?: Filter
-  _marks?: Set<string>
-  _deeps?: Map<string, number>
-  _kinds?: ReadonlyMap<string, Kind>
-  _chain?: readonly string[]
+  onFileDoubleClick?: (file: FileNode) => void
 }) {
   const file = useFile()
-  const level = props.level ?? 0
+  const live = () => props.allowed === undefined
   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 (
-    // 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>
     </div>
   )

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

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

+ 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 { 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 (
-    <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>
             </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>
-        </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"
 
 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 sourceName = (model: ModelInfo) => {
     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(", ")})` : ""
     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 = () => {
     if (props.model.capabilities) {
       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")
   }
   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 (
     <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,
     reviewOpen: false,
   })
+  const storyModel = {
+    id: "claude-3-7-sonnet",
+    name: "Claude 3.7 Sonnet",
+    provider: { id: "anthropic", name: "Anthropic" },
+  }
   const model = {
-    current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+    current: () => storyModel,
+    list: () => [storyModel],
+    visible: () => true,
+    set: () => {},
     variant: {
       list: () => ["fast", "thinking"],
       current: () => controls.variant,
@@ -67,7 +75,6 @@ function PromptInputExample() {
         open: () => setControls("reviewOpen", true),
       },
     },
-    newLayoutDesigns: true,
   }
   const addReviewComment = () => {
     const comment = controls.comments + 1
@@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
       },
       reviewPanel: { opened: () => false, open: () => {} },
     },
-    newLayoutDesigns: true,
   }
   const state = {
     blocked: () => false,

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

@@ -13,12 +13,11 @@ import {
   Match,
   type JSX,
 } 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 {
   ContentPart,
   DEFAULT_PROMPT,
+  isCommentItem,
   isPromptEqual,
   Prompt,
   usePrompt,
@@ -36,6 +35,8 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 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 { MenuV2 } from "@opencode-ai/ui/v2/menu-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 { useDialog } from "@opencode-ai/ui/context/dialog"
 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 { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
@@ -55,121 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
 import {
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
-  prependHistoryEntry,
   type PromptHistoryComment,
   type PromptHistoryEntry,
-  type PromptHistoryStoredEntry,
   promptLength,
 } 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 { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 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 { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 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 = [
   "prompt.example.1",
@@ -520,7 +435,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const setMode = (mode: "normal" | "shell") => {
     setStore("mode", mode)
-    setStore("popover", null)
+    setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
     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) => {
     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 handleBlur = () => {
-    savedCursor = currentCursor()
+    const cursor = currentCursor()
+    savedCursor = cursor
+    if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
     closePopover()
     setComposing(false)
   }
@@ -800,17 +717,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const handleSlashSelect = (cmd: SlashCommand | undefined) => {
     if (!cmd) return
+    const menu = store.slashMenu
     closePopover()
     const images = imageAttachments()
 
     if (cmd.type === "custom") {
       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)
       prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
       focusEditorEnd()
       return
     }
 
+    if (menu) {
+      command.trigger(cmd.id, "slash")
+      return
+    }
+
     clearEditor()
     prompt.set([...DEFAULT_PROMPT, ...images], 0)
     command.trigger(cmd.id, "slash")
@@ -1072,10 +1002,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       if (atMatch) {
         atOnInput(atMatch[1])
-        setStore("popover", "at")
+        setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
       } else if (slashMatch) {
         slashOnInput(slashMatch[1])
-        setStore("popover", "slash")
+        setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
       } else {
         closePopover()
       }
@@ -1199,7 +1129,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }
 
         setStore("mode", "normal")
-        setStore("popover", null)
+        closePopover()
         setStore("historyIndex", -1)
         setStore("savedPrompt", null)
         prompt.set(edit.prompt, promptLength(edit.prompt))
@@ -1286,13 +1216,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         resetHistoryNavigation(true)
       },
       setMode: (mode) => setStore("mode", mode),
-      setPopover: (popover) => setStore("popover", popover),
+      setPopover: (popover) => {
+        if (!popover) return closePopover()
+        setStore({ popover, slashMenu: false, slashMenuQuery: "" })
+      },
       newSessionWorktree: () => props.newSessionWorktree,
       onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
       shouldQueue: props.shouldQueue,
       onQueue: props.onQueue,
       onAbort: props.onAbort,
       onSubmit: props.onSubmit,
+      model: props.controls.model.selection,
     })
 
   const handleKeyDown = (event: KeyboardEvent) => {
@@ -1325,7 +1259,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const cursorPosition = getCursorPosition(editorRef)
       if (cursorPosition === 0) {
         setStore("mode", "shell")
-        setStore("popover", null)
+        closePopover()
         event.preventDefault()
         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 agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
   const providersLoading = () => props.controls.model.loading
@@ -1470,48 +1427,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     (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) => {
     editorRef = el
     restoreEndOnFocus = true
     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 (
     <div class="relative size-full flex flex-col gap-0">
       {(promptReady(), null)}
@@ -1527,715 +1447,344 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         slashActive={slashActive() ?? undefined}
         setSlashActive={setSlashActive}
         onSlashSelect={handleSlashSelect}
+        slashMenu={store.slashMenu}
+        slashMenuQuery={store.slashMenuQuery}
+        onSlashMenuInput={(value) => {
+          setStore("slashMenuQuery", value)
+          slashOnInput(value)
+        }}
+        onSlashMenuKeyDown={handleSlashMenuKeyDown}
         commandKeybind={command.keybind}
         commandKeybindParts={command.keybindParts}
-        newLayoutDesigns={props.controls.newLayoutDesigns}
+        newLayoutDesigns={false}
         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={{
-                "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>
-        </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
-              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
-                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={{
-                  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>
-          </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
-                    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 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
-                        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
                           placement="top"
                           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
                             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) => {
-                              props.controls.agents.select(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-agent" }}
+                            triggerProps={{ "data-action": "prompt-model-variant" }}
                             variant="ghost"
                           />
                         </TooltipKeybind>
                       </div>
                     </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>
-            </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>
-      <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
 }
 
-type PromptAttachmentsInput = {
+export type PromptAttachmentsInput = {
   prompt: ReturnType<typeof usePrompt>
   editor: () => HTMLDivElement | undefined
   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) {
-  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 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 { Dynamic } from "solid-js/web"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 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 { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
 import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
   active: (item: PromptContextItem) => boolean
   openComment: (item: PromptContextItem) => void
   remove: (item: PromptContextItem) => void
+  newLayoutDesigns: boolean
   t: (key: string) => string
 }
 
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
             const selected = props.active(item)
 
             return (
-              <TooltipV2
+              <Dynamic
+                component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
                 value={
                   <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}
                     </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>}
                   </Show>
                 </div>
-              </TooltipV2>
+              </Dynamic>
             )
           }}
         </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 { Icon } from "@opencode-ai/ui/icon"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 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 = {
   attachments: ImageAttachmentPart[]
   onOpen: (attachment: ImageAttachmentPart) => void
   onRemove: (id: string) => void
   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 imageClass =
   "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 =
   "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"
 
 export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
   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
-                  when={attachment.mime.startsWith("image/")}
+                  when={image}
                   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
                     src={attachment.dataUrl}
                     alt={attachment.filename}
-                    class={imageClass}
+                    class={props.newLayoutDesigns ? imageClassV2 : imageClass}
                     onClick={() => props.onOpen(attachment)}
                   />
                 </Show>
+              )
+              const name = () => (
+                <div class={nameClass}>
+                  <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+                </div>
+              )
+              const remove = () => (
                 <button
                   type="button"
                   onClick={() => props.onRemove(attachment.id)}
-                  class={removeClass}
+                  class={props.newLayoutDesigns ? removeClassV2 : removeClass}
                   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>
-                <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>
     </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")
   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
   setSlashActive: (id: string) => void
   onSlashSelect: (item: SlashCommand) => void
+  slashMenu: boolean
+  slashMenuQuery: string
+  onSlashMenuInput: (value: string) => void
+  onSlashMenuKeyDown: (event: KeyboardEvent) => void
   commandKeybind: (id: string) => string | undefined
   commandKeybindParts: (id: string) => string[]
   newLayoutDesigns: boolean
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
             </Show>
           </Match>
           <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
               when={props.slashFlat.length > 0}
               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 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
 
 const createdClients: string[] = []
 const createdSessions: string[] = []
-const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
+const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
 const optimistic: Array<{
   directory?: string
   sessionID?: string
@@ -26,13 +28,25 @@ let params: { id?: string } = {}
 let search: { draftId?: string } = {}
 let selected = "/repo/worktree-a"
 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 [promptStore, setPromptStore] = createStore<PromptStore>({
+  prompt: promptValue,
+  cursor: 0,
+  context: { items: [] },
+})
 const prompt = {
+  store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
   ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
   current: () => promptValue,
   cursor: () => 0,
   dirty: () => true,
+  model: {
+    current: () => undefined,
+    set: () => undefined,
+  },
   reset: () => undefined,
   set: () => undefined,
   context: {
@@ -51,6 +65,7 @@ const clientFor = (directory: string) => {
   return {
     session: {
       create: async () => {
+        await createSessionGate
         createdSessions.push(directory)
         return {
           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) {
-        enabledAutoAccept.push({ sessionID, directory })
+        enabledAutoAccept.push({ server, sessionID, directory })
       },
-    }),
-  }))
+    })
+    return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
+  })
 
   mock.module("@/context/server", () => ({
     useServer: () => ({ key: "server-key" }),
@@ -246,6 +262,8 @@ beforeEach(() => {
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
   variant = undefined
+  permissionServer = "server-a"
+  createSessionGate = undefined
   for (const key of Object.keys(storedSessions)) delete storedSessions[key]
 })
 
@@ -313,7 +331,40 @@ describe("prompt submit worktree selection", () => {
 
     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 () => {
@@ -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 () => {
     const submit = createPromptSubmit({
       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 { Binary } from "@opencode-ai/core/util/binary"
 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 { useServerSync, type ServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
 import { usePermission } from "@/context/permission"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
 import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
   onQueue?: (draft: FollowupDraft) => void
   onAbort?: () => void
   onSubmit?: () => void
+  model?: ModelSelection
 }
 
 export function createPromptSubmit(input: PromptSubmitInput) {
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return
     }
 
-    const currentModel = local.model.current()
+    const modelSelection = input.model ?? local.model
+    const currentModel = modelSelection.current()
     const currentAgent = local.agent.current()
-    const variant = local.model.variant.current()
+    const variant = modelSelection.variant.current()
     if (!currentModel || !currentAgent) {
       showToast({
         title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -313,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     input.resetHistoryNavigation()
 
     const projectDirectory = sdk().directory
+    const permissionState = permission.currentServerState()
     const isNewSession = !params.id
     const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -374,13 +377,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       if (created) {
         seed(sessionDirectory, 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) {

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

@@ -4,37 +4,40 @@ import type { PromptHistoryEntry } from "./history"
 
 export type PromptInputTransientState = {
   popover: "at" | "slash" | null
+  slashMenu: boolean
+  slashMenuQuery: string
   historyIndex: number
   savedPrompt: PromptHistoryEntry | null
   placeholder: number
   draggingType: "image" | "@mention" | null
   mode: "normal" | "shell"
   applyingHistory: boolean
-  variantOpen: boolean
 }
 
 function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
   setStore({
     popover: null,
+    slashMenu: false,
+    slashMenuQuery: "",
     historyIndex: -1,
     savedPrompt: null,
     draggingType: null,
     mode: "normal",
     applyingHistory: false,
-    variantOpen: false,
   })
 }
 
 export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
   const [store, setStore] = createStore<PromptInputTransientState>({
     popover: null,
+    slashMenu: false,
+    slashMenuQuery: "",
     historyIndex: -1,
     savedPrompt: null,
     placeholder,
     draggingType: null,
     mode: "normal",
     applyingHistory: false,
-    variantOpen: false,
   })
 
   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 { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -46,7 +55,7 @@ export function createPromptProjectController(input: {
   const [store, setStore] = createStore({ open: false, search: "", active: "" })
   let searchRef: HTMLInputElement | undefined
 
-  const selected = () => {
+  const current = () => {
     const key = pathKey(input.controls().directory)
     return input
       .controls()
@@ -56,6 +65,7 @@ export function createPromptProjectController(input: {
           (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
       )
   }
+  const selected = () => current() ?? input.controls().available[0]
   const projects = () => {
     const search = store.search.trim().toLowerCase()
     if (!search) return input.controls().available
@@ -91,8 +101,8 @@ export function createPromptProjectController(input: {
   }
   const select = (project: PromptProject) => {
     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)
     }
@@ -115,6 +125,7 @@ export function createPromptProjectController(input: {
 
   return {
     selected,
+    empty: () => input.controls().available.length === 0,
     projects,
     servers,
     projectKey,
@@ -184,9 +195,28 @@ export function PromptProjectSelector(props: {
   controller: PromptProjectController
   placement?: "bottom" | "bottom-start"
 }) {
+  const [triggerReady, setTriggerReady] = createSignal(false)
   let contentRef: HTMLDivElement | undefined
+  let triggerFrame: number | undefined
   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 = () =>
     props.controller.active()
       ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@@ -257,13 +287,13 @@ export function PromptProjectSelector(props: {
 
   return (
     <DropdownMenu
-      open={props.controller.open()}
+      open={triggerReady() && props.controller.open()}
       placement={props.placement ?? "bottom"}
       gutter={4}
       modal={false}
       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.Content
           ref={contentRef}

+ 29 - 9
packages/app/src/components/prompt-workspace-selector.tsx

@@ -1,5 +1,6 @@
 import { For, Show } from "solid-js"
 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 as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { getFilename } from "@opencode-ai/core/util/path"
@@ -92,17 +93,36 @@ export function PromptWorkspaceSelector(props: {
           </MenuV2.Content>
         </MenuV2.Portal>
       </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]">
               <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>
-          </>
-        )}
-      </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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { createMediaQuery } from "@solid-primitives/media"
 
 import { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
@@ -11,9 +12,10 @@ import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
 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 { createSessionTabs } from "@/pages/session/helpers"
+import { useSettings } from "@/context/settings"
 
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const layout = useLayout()
   const language = useLanguage()
   const sdk = useSDK()
+  const settings = useSettings()
   const providers = useProviders(() => sdk().directory)
   const { params, tabs, view } = useSessionLayout()
+  const isDesktop = createMediaQuery("(min-width: 768px)")
 
   const variant = createMemo(() => props.variant ?? "button")
   const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
     tabs,
     pathFromTab: file.pathFromTab,
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+    fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
   })
   const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -69,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   )
 
   const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
-  const tokens = createMemo(() => info()?.tokens)
   const cost = createMemo(() => {
     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.tokens")}
-        value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
+        value={context()?.total.toLocaleString(language.intl()) ?? "0"}
       />
     </div>
   )

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

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

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

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

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

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

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

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { Message } from "@opencode-ai/sdk/v2/client"
-import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
+import { getSessionContext } from "./session-context-metrics"
 
 const assistant = (
   id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
 }
 
 describe("getSessionContext", () => {
-  test("computes usage from latest assistant with tokens", () => {
+  test("computes token totals and usage from latest assistant with tokens", () => {
     const messages = [
       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),
     ]
     const providers = [
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
     const ctx = getSessionContext(messages, providers)
 
     expect(ctx?.message.id).toBe("a2")
+    expect(ctx?.total).toBe(500)
+    expect(ctx?.input).toBe(300)
     expect(ctx?.usage).toBe(50)
     expect(ctx?.providerLabel).toBe("OpenAI")
     expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
 
     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 = {
   id: string
@@ -21,6 +21,7 @@ type Context = {
   modelLabel: string
   limit: number | undefined
   input: number
+  total: number
   usage: number | null
 }
 
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
     modelLabel: model?.name ?? message.modelID,
     limit,
     input: message.tokens.input,
+    total,
     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[] = []) {
   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 { useSDK } from "@/context/sdk"
 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 { createSessionContextFormatter } from "./session-context-format"
 
@@ -135,7 +135,6 @@ export function SessionContextTab() {
   )
 
   const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
-  const tokens = createMemo(() => info()?.tokens)
   const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
 
   const cost = createMemo(() => {
@@ -204,14 +203,15 @@ export function SessionContextTab() {
     { label: "context.stats.provider", value: providerLabel },
     { label: "context.stats.model", value: modelLabel },
     { 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.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",
-      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.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 { messageAgentColor } from "@/utils/agent"
 import { decode64 } from "@/utils/base64"
+import { fileManagerApp } from "@/utils/file-manager"
 import { Persist, persisted } from "@/utils/persist"
 import { StatusPopover, StatusPopoverV2 } from "../status-popover"
 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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { reviewTooltipKeybind } from "../command-tooltip-keybind"
+import { useTitlebarRightMount } from "../titlebar"
 
 const OPEN_APPS = [
   "vscode",
@@ -174,11 +176,7 @@ export function SessionHeader() {
     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(() => {
     if (platform.platform !== "desktop") return
@@ -284,10 +282,9 @@ export function SessionHeader() {
   }
 
   const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
-  const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
+  const rightMount = useTitlebarRightMount()
   onMount(() => {
     setCenterMount(document.getElementById("opencode-titlebar-center"))
-    setRightMount(document.getElementById("opencode-titlebar-right"))
   })
 
   return (

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