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

chore: merge dev into v2 (#39290)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Devin R Leopold <devin.leopold@gmail.com>
Co-authored-by: Zach Bruggeman <mail@bruggie.com>
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Siewert <david1gruppenplan@gmail.com>
Co-authored-by: Andrei Dziahel <develop7@develop7.info>
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
opencode-agent[bot] 2 недель назад
Родитель
Сommit
302e9b45ab
100 измененных файлов с 4087 добавлено и 1414 удалено
  1. 26 30
      bun.lock
  2. 4 4
      nix/hashes.json
  3. 220 0
      packages/app/V1_API_MIGRATION.md
  4. 1 1
      packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts
  5. 3 3
      packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts
  6. 4 0
      packages/app/e2e/performance/timeline-stability/tools.spec.ts
  7. 27 6
      packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts
  8. 1 1
      packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts
  9. 3 2
      packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts
  10. 25 7
      packages/app/e2e/regression/cross-server-tab-close.spec.ts
  11. 132 0
      packages/app/e2e/regression/open-file-expand-folder.spec.ts
  12. 37 5
      packages/app/e2e/regression/remote-session-settings.spec.ts
  13. 30 8
      packages/app/e2e/regression/remote-tab-busy.spec.ts
  14. 3 2
      packages/app/e2e/regression/review-line-comment.spec.ts
  15. 1 1
      packages/app/e2e/regression/review-open-file.spec.ts
  16. 2 1
      packages/app/e2e/regression/review-state-persistence.spec.ts
  17. 49 6
      packages/app/e2e/regression/review-terminal-stacked.spec.ts
  18. 2 2
      packages/app/e2e/regression/session-list-path-loading.spec.ts
  19. 8 4
      packages/app/e2e/regression/session-request-docks.spec.ts
  20. 1 1
      packages/app/e2e/regression/session-timeline-file-projection.spec.ts
  21. 1 1
      packages/app/e2e/regression/session-timeline-file-state.spec.ts
  22. 3 1
      packages/app/e2e/regression/session-timeline-projection.spec.ts
  23. 4 2
      packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
  24. 190 0
      packages/app/e2e/regression/session-todo-dock-navigation.spec.ts
  25. 7 4
      packages/app/e2e/regression/subagent-child-navigation.spec.ts
  26. 57 6
      packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
  27. 36 18
      packages/app/e2e/regression/terminal-composer-focus.spec.ts
  28. 42 5
      packages/app/e2e/regression/terminal-hidden.spec.ts
  29. 29 9
      packages/app/e2e/regression/terminal-tab-switch.spec.ts
  30. 3 2
      packages/app/e2e/smoke/session-timeline.fixture.ts
  31. 97 0
      packages/app/e2e/user-story/model-selection-flow.spec.ts
  32. 41 6
      packages/app/e2e/utils/mock-server.ts
  33. 8 0
      packages/app/e2e/utils/sse-transport.ts
  34. 3 3
      packages/app/package.json
  35. 26 1
      packages/app/src/app.tsx
  36. 8 6
      packages/app/src/components/command-palette.ts
  37. 1 2
      packages/app/src/components/dialog-command-palette-v2.tsx
  38. 89 113
      packages/app/src/components/dialog-connect-provider.tsx
  39. 2 1
      packages/app/src/components/dialog-custom-provider.tsx
  40. 3 7
      packages/app/src/components/dialog-fork.tsx
  41. 30 12
      packages/app/src/components/dialog-select-directory-v2.tsx
  42. 6 4
      packages/app/src/components/dialog-select-directory.tsx
  43. 2 2
      packages/app/src/components/dialog-select-mcp.tsx
  44. 2 1
      packages/app/src/components/dialog-select-model-unpaid-v2.tsx
  45. 94 86
      packages/app/src/components/dialog-select-model.tsx
  46. 19 1
      packages/app/src/components/dialog-select-server.tsx
  47. 27 4
      packages/app/src/components/directory-picker-domain.test.ts
  48. 13 10
      packages/app/src/components/directory-picker-domain.ts
  49. 21 7
      packages/app/src/components/edit-project.ts
  50. 80 88
      packages/app/src/components/prompt-input-v2.tsx
  51. 107 2
      packages/app/src/components/prompt-input.stories.tsx
  52. 26 24
      packages/app/src/components/prompt-input.tsx
  53. 124 19
      packages/app/src/components/prompt-input/submit.test.ts
  54. 68 33
      packages/app/src/components/prompt-input/submit.ts
  55. 51 55
      packages/app/src/components/prompt-project-selector.tsx
  56. 54 17
      packages/app/src/components/server/server-row-menu.tsx
  57. 0 1
      packages/app/src/components/session/index.ts
  58. 0 16
      packages/app/src/components/session/session-new-design-view.tsx
  59. 8 5
      packages/app/src/components/settings-general.tsx
  60. 289 61
      packages/app/src/components/settings-keybinds.tsx
  61. 31 26
      packages/app/src/components/settings-providers.tsx
  62. 18 2
      packages/app/src/components/settings-v2/dialog-settings-v2.tsx
  63. 8 5
      packages/app/src/components/settings-v2/general.tsx
  64. 83 34
      packages/app/src/components/settings-v2/models.tsx
  65. 40 30
      packages/app/src/components/settings-v2/providers.tsx
  66. 56 2
      packages/app/src/components/settings-v2/settings-v2.css
  67. 35 24
      packages/app/src/components/status-popover-body.tsx
  68. 24 2
      packages/app/src/components/status-popover-indicator.test.ts
  69. 15 4
      packages/app/src/components/status-popover-indicator.ts
  70. 19 1
      packages/app/src/components/status-popover.tsx
  71. 59 26
      packages/app/src/components/terminal.tsx
  72. 2 2
      packages/app/src/components/titlebar-tab-gesture.ts
  73. 80 106
      packages/app/src/components/titlebar-tab-nav.tsx
  74. 23 0
      packages/app/src/components/titlebar-tab-order.test.ts
  75. 12 0
      packages/app/src/components/titlebar-tab-order.ts
  76. 127 43
      packages/app/src/components/titlebar-tab-strip.tsx
  77. 13 128
      packages/app/src/components/titlebar.tsx
  78. 22 7
      packages/app/src/context/command.test.ts
  79. 14 5
      packages/app/src/context/command.tsx
  80. 9 5
      packages/app/src/context/directory-sync.ts
  81. 11 3
      packages/app/src/context/file.tsx
  82. 23 0
      packages/app/src/context/file/path.test.ts
  83. 6 1
      packages/app/src/context/file/path.ts
  84. 5 5
      packages/app/src/context/file/watcher.test.ts
  85. 1 1
      packages/app/src/context/file/watcher.ts
  86. 139 71
      packages/app/src/context/global-sync/bootstrap.test.ts
  87. 223 56
      packages/app/src/context/global-sync/bootstrap.ts
  88. 52 0
      packages/app/src/context/global-sync/child-store.test.ts
  89. 33 6
      packages/app/src/context/global-sync/child-store.ts
  90. 36 3
      packages/app/src/context/global-sync/event-reducer.test.ts
  91. 108 20
      packages/app/src/context/global-sync/event-reducer.ts
  92. 154 0
      packages/app/src/context/global-sync/home-session-index.test.ts
  93. 174 0
      packages/app/src/context/global-sync/home-session-index.ts
  94. 20 0
      packages/app/src/context/global-sync/mcp.test.ts
  95. 3 2
      packages/app/src/context/global-sync/mcp.ts
  96. 13 10
      packages/app/src/context/global-sync/session-cache.test.ts
  97. 8 9
      packages/app/src/context/global-sync/session-cache.ts
  98. 22 14
      packages/app/src/context/global-sync/session-load.ts
  99. 12 19
      packages/app/src/context/global-sync/types.ts
  100. 104 23
      packages/app/src/context/global-sync/utils.test.ts

Разница между файлами не показана из-за своего большого размера
+ 26 - 30
bun.lock


+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
-    "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
-    "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
-    "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
+    "x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
+    "aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
+    "aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
+    "x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
   }
 }

+ 220 - 0
packages/app/V1_API_MIGRATION.md

@@ -0,0 +1,220 @@
+# V1 API Migration Checklist
+
+The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
+
+## Events
+
+- [x] Replace `GET /global/event` with `GET /api/event`.
+  - `src/context/server-sdk.tsx`
+- [x] Reduce current granular session and message events into the existing app projections.
+  - `src/context/server-session-v2-reducer.ts`
+  - `src/context/server-session.ts`
+- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/context/server-session.ts`
+  - `src/context/notification.tsx`
+  - `src/pages/session/usage-exceeded-dialogs.tsx`
+- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/context/server-session.ts`
+- [x] Adapt current permission and question events to the existing request model.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/context/permission.tsx`
+- [x] Consume current file watcher events.
+  - `src/context/file.tsx`
+- [x] Consume current VCS events.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/pages/session.tsx`
+- [x] Consume current `pty.exited` events.
+  - `src/context/terminal.tsx`
+- [ ] Migrate LSP and reference events.
+  - `src/context/global-sync/event-reducer.ts`
+
+## Sessions
+
+- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
+  - `src/context/server-sync.tsx`
+- [x] Migrate session listing from `GET /session`.
+  - `src/context/server-sync.tsx`
+  - `src/context/directory-sync.ts`
+  - `src/pages/layout.tsx`
+- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
+  - `src/components/titlebar.tsx`
+- [x] Migrate session updates from `PATCH /session/:sessionID`.
+  - `src/context/directory-sync.ts`
+  - `src/context/layout.tsx`
+  - `src/pages/home.tsx`
+  - `src/pages/layout.tsx`
+  - `src/pages/session/timeline/message-timeline.tsx`
+  - `src/components/titlebar-tab-nav.tsx`
+  - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
+- [x] Migrate session deletion from `DELETE /session/:sessionID`.
+  - `src/pages/session/timeline/message-timeline.tsx`
+- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
+  - Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
+- [x] Migrate abort from `POST /session/:sessionID/abort`.
+  - `src/components/prompt-input/submit.ts`
+  - `src/pages/session/use-session-commands.tsx`
+  - `src/pages/session.tsx`
+- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
+  - `src/pages/session/use-session-commands.tsx`
+  - `src/pages/session.tsx`
+- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
+  - `src/pages/session/use-session-commands.tsx`
+- [x] Migrate slash commands from `POST /session/:sessionID/command`.
+  - `src/components/prompt-input/submit.ts`
+- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
+  - `src/components/prompt-input/submit.ts`
+- [x] Migrate session fork from `POST /session/:sessionID/fork`.
+  - `src/components/dialog-fork.tsx`
+- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
+  - `src/pages/session/use-session-commands.tsx`
+  - `src/pages/session/timeline/message-timeline.tsx`
+  - Blocked: the current API has no sharing contract or implementation.
+
+## Session Compatibility Fallbacks
+
+These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
+
+- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
+  - `src/context/server-session.ts`
+- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
+  - `src/context/server-session.ts`
+- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
+  - `src/context/server-session.ts`
+
+## Filesystem
+
+- [ ] Migrate file listing from `GET /file`.
+  - `src/context/file.tsx`
+- [ ] Migrate file reads from `GET /file/content`.
+  - `src/context/file.tsx`
+  - `src/pages/session/review-tab.tsx`
+  - `src/pages/session/v2/review-panel-v2.tsx`
+- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
+  - `src/context/global-sync/bootstrap.ts`
+  - `src/components/dialog-select-directory.tsx`
+  - `src/components/dialog-select-directory-v2.tsx`
+
+## Projects And Worktrees
+
+- [x] Migrate project listing from `GET /project` to `GET /api/project`.
+  - `src/context/global-sync/bootstrap.ts`
+- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
+  - `src/context/global-sync/bootstrap.ts`
+- [ ] Migrate Git initialization from `POST /project/git/init`.
+  - `src/pages/session.tsx`
+- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
+  - `src/context/layout.tsx`
+  - `src/components/edit-project.ts`
+  - `src/pages/layout.tsx`
+- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
+  - `src/pages/layout.tsx`
+  - `src/components/prompt-input/submit.ts`
+  - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
+- [ ] Migrate instance disposal from `POST /instance/dispose`.
+  - `src/pages/layout.tsx`
+
+## VCS
+
+- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
+  - `src/context/global-sync/bootstrap.ts`
+- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
+  - `src/pages/session.tsx`
+- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
+  - `src/pages/layout.tsx`
+
+## Configuration And Authentication
+
+- [ ] Migrate global configuration reads from `GET /global/config`.
+  - `src/context/global-sync/bootstrap.ts`
+- [ ] Migrate directory configuration reads from `GET /config`.
+  - `src/context/global-sync/bootstrap.ts`
+- [ ] Migrate global configuration updates from `PATCH /global/config`.
+  - `src/context/server-sync.tsx`
+- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
+  - `src/components/dialog-connect-provider.tsx`
+- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
+  - `src/components/dialog-connect-provider.tsx`
+- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
+  - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
+  - `src/components/dialog-connect-provider.tsx`
+  - `src/components/dialog-custom-provider.tsx`
+  - `src/components/settings-providers.tsx`
+  - `src/components/settings-v2/providers.tsx`
+- [ ] Migrate global disposal from `POST /global/dispose`.
+  - `src/components/dialog-connect-provider.tsx`
+  - `src/components/settings-providers.tsx`
+  - `src/components/settings-v2/providers.tsx`
+
+## Permissions And Questions
+
+- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
+  - `src/context/global-sync/bootstrap.ts`
+  - `src/context/permission.tsx`
+- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
+  - `src/context/permission.tsx`
+  - `src/pages/session/composer/session-composer-state.ts`
+- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
+  - `src/context/global-sync/bootstrap.ts`
+- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
+  - `src/pages/session/composer/session-question-dock.tsx`
+
+## Commands, MCP, LSP, And References
+
+- [x] Migrate command listing from `GET /command` to `GET /api/command`.
+  - `src/context/global-sync/bootstrap.ts`
+  - `src/context/server-sync.tsx`
+- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
+  - `src/context/server-sync.tsx`
+- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
+  - `src/context/server-sync.tsx`
+- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
+  - `src/context/server-sync.tsx`
+- [ ] Migrate LSP status from `GET /lsp`.
+  - `src/context/server-sync.tsx`
+- [x] Move `GET /api/reference` off the legacy generated SDK transport.
+  - `src/context/global-sync/bootstrap.ts`
+
+## Search
+
+- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
+  - `src/components/command-palette.ts`
+  - `src/components/dialog-command-palette-v2.tsx`
+
+## PTY And Terminal
+
+- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
+  - `src/context/terminal.tsx`
+  - `src/components/terminal.tsx`
+- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
+  - `src/components/settings-general.tsx`
+  - `src/components/settings-v2/general.tsx`
+- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
+  - `src/components/terminal.tsx`
+- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
+  - `src/components/terminal.tsx`
+
+## Legacy Types And Adapters
+
+These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
+
+- [ ] Replace the current-session-to-legacy-session adapter.
+  - `src/utils/session.ts`
+- [ ] Replace the current-message-to-legacy-message-and-part adapter.
+  - `src/utils/session-message.ts`
+- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
+  - `src/context/global-sync/utils.ts`
+- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
+- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
+  - `package.json`
+
+## Test Infrastructure
+
+- [ ] Replace V1 endpoint mocks with current API mocks.
+  - `e2e/utils/mock-server.ts`
+- [x] Replace `/global/event` and `/event` interception with current event transport handling.
+  - `e2e/utils/sse-transport.ts`
+- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
+  - `e2e/performance/timeline-stability/fixture.ts`
+- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.

+ 1 - 1
packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts

@@ -20,7 +20,7 @@ const profiles = [
   { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
   {
     name: "multi patch",
-    tool: "patch",
+    tool: "apply_patch",
     input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
   },
 ] as const

+ 3 - 3
packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts

@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
       userMessage(),
       assistantMessage(
         [
-          toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
+          toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
           textPart(followingID, "Following incremental patch"),
         ],
         { completed: false },
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
     partUpdated(
       toolPart(
         patchID,
-        "patch",
+        "apply_patch",
         "running",
         { files: [first.filePath, second.filePath] },
         { metadata: { files: [first, second] } },
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
     partUpdated(
       toolPart(
         patchID,
-        "patch",
+        "apply_patch",
         "completed",
         { files: [first.filePath, second.filePath, third.filePath] },
         { metadata: { files: [first, second, third] } },

+ 4 - 0
packages/app/e2e/performance/timeline-stability/tools.spec.ts

@@ -33,9 +33,11 @@ test.describe("timeline tool state stability", () => {
     }
     const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
     const questionID = "prt_state_question"
+    const todoID = "prt_state_todo"
     const initial = [
       ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
       toolPart(questionID, "question", "pending", questionInput()),
+      toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
       textPart("prt_state_following", "Following lightweight tools"),
     ]
     const childID = "ses_timeline_child"
@@ -47,6 +49,7 @@ test.describe("timeline tool state stability", () => {
     await timeline.send(status("busy"), 120)
     for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
     await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
+    await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
 
     const regionIDs = [
       "prt_state_webfetch",
@@ -102,6 +105,7 @@ test.describe("timeline tool state stability", () => {
       ]),
     )
     await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
+    await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
     await expect(
       page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
     ).toBeVisible()

+ 27 - 6
packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts

@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
 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
+const lastAssistant = assistants.at(-1)!
+const lastPart = lastAssistant.parts.at(-1)!
+const lastPartID =
+  lastPart.type === "tool"
+    ? lastPart.id
+    : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
 
 benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
   benchmark.setTimeout(180_000)
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
       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 page.route(`**/session/${fixture.targetID}`, (route) => {
+    const current = new URL(route.request().url()).pathname.startsWith("/api/")
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(
+        current
+          ? {
+              data: {
+                ...target,
+                cost: 0,
+                tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+                location: { directory: target.directory },
+              },
+            }
+          : target,
+      ),
+    })
+  })
   await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
   await page.goto(stressSessionHref(fixture.sourceID))
   await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
     parent: requests.filter((request) => request.type === "parent").length,
   }
   if (mode === "candidate") {
-    expect(requestCounts.parent).toBe(1)
-    expect(historyGates).toBe(1)
+    expect(requestCounts.parent).toBe(0)
+    expect(historyGates).toBe(0)
   }
   return { metrics, requestCounts, historyGateCount: historyGates }
 }

+ 1 - 1
packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts

@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
             messageID: assistantID,
             type: "tool",
             callID: `call_0000_${suffix}_patch`,
-            tool: "patch",
+            tool: "apply_patch",
             state: {
               status: "completed",
               input: { patchText: realisticPatch(index) },

+ 3 - 2
packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts

@@ -131,7 +131,7 @@ function toolPart(
 ): MessagePart {
   const metadata =
     metadataOverride ??
-    (tool === "patch"
+    (tool === "apply_patch"
       ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
       : tool === "edit" || tool === "write"
         ? {
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
       ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
       : []),
     ...(index % 8 === 0
-      ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
+      ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
       : []),
     ...(index % 7 === 0
       ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
@@ -269,6 +269,7 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
 ]).flat()
 
 function renderable(part: MessagePart) {
+  if (part.type === "tool" && part.tool === "todowrite") return false
   if (part.type === "text") return !!part.text.trim()
   if (part.type === "reasoning") return !!part.text.trim()
   return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"

+ 25 - 7
packages/app/e2e/regression/cross-server-tab-close.spec.ts

@@ -1,5 +1,6 @@
 import { expect, test, type Page, type Route } from "@playwright/test"
 import { base64Encode } from "@opencode-ai/core/util/encode"
+import { currentSession } from "../utils/mock-server"
 
 const serverA = "http://127.0.0.1:4096"
 const serverB = "http://127.0.0.1:4097"
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
   await tabA.locator('[data-slot="tab-close"] button').click()
 
   await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
-  await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
+  await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
   await expect(page.getByText(sessionB.title).first()).toBeVisible()
   const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
   expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
     const current = url.origin === serverA ? sessionA : sessionB
     const directory = url.searchParams.get("directory")
     if (directory && directory !== current.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") return json(route, [current])
+    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+      return sse(route)
+    if (url.pathname === "/global/health") return json(route, {}, 404)
+    if (url.pathname === "/api/health") return json(route, { pid: 1 })
+    if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
+    if (url.pathname === "/api/session/active") return json(route, { data: {} })
+    if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
+    if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
     if (url.pathname === `/session/${current.id}`) return json(route, current)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
     if (url.pathname === `/session/${current.id}/message`) return json(route, [])
-    if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
     if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
       return json(route, [])
-    if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].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, { all: [], connected: [], default: { providerID: "", modelID: "" } })
     if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
         directory: current.directory,
         home: current.directory,
       })
+    if (url.pathname === "/api/path")
+      return json(route, {
+        state: current.directory,
+        config: current.directory,
+        worktree: current.directory,
+        directory: current.directory,
+        home: current.directory,
+      })
     if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    if (url.pathname === "/api/vcs")
+      return json(route, {
+        location: { directory: current.directory },
+        data: { branch: "main", defaultBranch: "main" },
+      })
     return json(route, {})
   })
 }

+ 132 - 0
packages/app/e2e/regression/open-file-expand-folder.spec.ts

@@ -0,0 +1,132 @@
+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/OpenFileExpand"
+const projectID = "proj_open_file_expand"
+const sessionID = "ses_open_file_expand"
+const title = "Open file expand"
+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("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "open-file-expand",
+      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 === "frontend\\" || path === "frontend") {
+        return [
+          {
+            name: "app.ts",
+            path: "frontend\\app.ts",
+            absolute: `${directory}/frontend/app.ts`,
+            type: "file" as const,
+            ignored: false,
+          },
+        ]
+      }
+      if (path) return []
+      return [
+        {
+          name: "frontend",
+          path: "frontend\\",
+          absolute: `${directory}/frontend`,
+          type: "directory" as const,
+          ignored: false,
+        },
+        {
+          name: "README.md",
+          path: "README.md",
+          absolute: `${directory}/README.md`,
+          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, shouldDisplayTabsToast: false } }),
+      )
+      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 },
+  )
+
+  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()
+
+  const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
+  await expect(frontendRow).toBeVisible()
+  await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
+  await frontendRow.click()
+  await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
+
+  const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
+  await expect(appRow).toBeVisible()
+  await appRow.click()
+  await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
+  await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
+})

+ 37 - 5
packages/app/e2e/regression/remote-session-settings.spec.ts

@@ -1,6 +1,7 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { expect, test, type Page, type Route } from "@playwright/test"
 import { installSseTransport } from "../utils/sse-transport"
+import { currentSession } from "../utils/mock-server"
 
 const serverA = "http://127.0.0.1:4096"
 const serverB = "http://127.0.0.1:4097"
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ 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+,")
+  await page.keyboard.press("Control+,")
 
   const dialog = page.locator(".settings-v2-dialog")
   const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
   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+,")
+  await page.keyboard.press("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()
@@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
       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/event" || url.pathname === "/event" || url.pathname === "/api/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)
+    if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
+      return json(route, { data: [] })
+    if (url.pathname === "/api/model/default") return json(route, { data: null })
+    if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
+      return json(route, { location: { directory }, data: [] })
+    if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
+    if (url.pathname === "/api/mcp/resource")
+      return json(route, { location: { directory }, data: { resources: [], templates: [] } })
+    if (url.pathname === "/api/project") {
+      return json(route, [
+        {
+          id: remote ? sessionB.projectID : "project-server-a",
+          worktree: directory,
+          vcs: "git",
+          time: { created: 1, updated: 1 },
+          sandboxes: [],
+        },
+      ])
+    }
+    if (url.pathname === "/api/project/current")
+      return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
+    if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
+    if (url.pathname === "/api/session/active") return json(route, { data: {} })
+    const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
+    if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
+    if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
+      return json(route, { data: [], cursor: {} })
     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)
@@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
         directory,
         home: directory,
       })
+    if (url.pathname === "/api/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" })
+    if (url.pathname === "/api/vcs")
+      return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
+    if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
     return json(route, {})
   })
 }

+ 30 - 8
packages/app/e2e/regression/remote-tab-busy.spec.ts

@@ -1,5 +1,6 @@
 import { expect, test, type Page, type Route } from "@playwright/test"
 import { base64Encode } from "@opencode-ai/core/util/encode"
+import { currentSession } from "../utils/mock-server"
 
 const serverA = "http://127.0.0.1:4096"
 const serverB = "http://127.0.0.1:4097"
@@ -57,15 +58,19 @@ async function mockServers(page: Page) {
     const current = url.origin === serverA ? sessionA : sessionB
     const directory = url.searchParams.get("directory")
     if (directory && directory !== current.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, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
-    if (url.pathname === "/session") return json(route, [current])
+    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+      return sse(route, url.pathname === "/api/event")
+    if (url.pathname === "/global/health") return json(route, {}, 404)
+    if (url.pathname === "/api/health") return json(route, { pid: 1 })
+    if (url.pathname === "/api/session/active")
+      return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
+    if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
+    if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
+    if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
     if (url.pathname === `/session/${current.id}`) return json(route, current)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
     if (url.pathname === `/session/${current.id}/message`) return json(route, [])
-    if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
     if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
       return json(route, [])
     if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
@@ -90,7 +95,20 @@ async function mockServers(page: Page) {
         directory: current.directory,
         home: current.directory,
       })
+    if (url.pathname === "/api/path")
+      return json(route, {
+        state: current.directory,
+        config: current.directory,
+        worktree: current.directory,
+        directory: current.directory,
+        home: current.directory,
+      })
     if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    if (url.pathname === "/api/vcs")
+      return json(route, {
+        location: { directory: current.directory },
+        data: { branch: "main", defaultBranch: "main" },
+      })
     return json(route, {})
   })
 }
@@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
   })
 }
 
-function sse(route: Route) {
-  return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
+function sse(route: Route, current: boolean) {
+  return route.fulfill({
+    status: 200,
+    contentType: "text/event-stream",
+    body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
+  })
 }

+ 3 - 2
packages/app/e2e/regression/review-line-comment.spec.ts

@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
 async function openReview(page: Page) {
   await page.setViewportSize({ width: 700, height: 900 })
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: "proj_review_line_comment_regression",
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, title)
-  const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
+  const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
   await page.getByRole("tab", { name: "Changes" }).click()
-  expect(await (await diffResponse).json()).toHaveLength(1)
+  expect((await (await diffResponse).json()).data).toHaveLength(1)
 
   const review = page.locator('[data-component="session-review"]')
   await expectAppVisible(review)

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

@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
   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 panel.locator("#session-side-panel-review-tab").click()
   await expect(sidebarToggle).toBeEnabled()
   await panel.getByRole("tab", { name: "Open file" }).click()
   await page.keyboard.press("Control+w")

+ 2 - 1
packages/app/e2e/regression/review-state-persistence.spec.ts

@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
 
 async function selectMode(page: Page, current: string, next: string) {
   await page.getByRole("button", { name: current }).click()
-  await page.getByRole("option", { name: next }).click()
+  await page.getByRole("option", { name: next }).dispatchEvent("click")
 }
 
 async function selectFile(page: Page, file: string) {
@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
 
 async function setup(page: Page) {
   await mockOpenCodeServer(page, {
+    protocol: "v1",
     directory,
     project: {
       id: projectID,

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

@@ -20,10 +20,12 @@ const branchDiffs = [
 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> }> = []
+  const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
   let detailVersion = 1
   let detailFailures = 1
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
+    protocol: "v1",
     directory,
     project: {
       id: projectID,
@@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
         time: { created: 1700000000000, updated: 1700000000000 },
       },
     ],
-    sessionStatus: { [sessionID]: { type: "idle" } },
+    sessionStatus: () => sessionStatus,
     pageMessages: () => ({ items: [] }),
     events: () => events.splice(0, 1),
     eventRetry: 16,
@@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
+      body: JSON.stringify({
+        branch: "review-pane-performance",
+        default_branch: "dev",
+      }),
     }),
   )
   await page.route("**/vcs/diff**", (route) => {
@@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
       ),
     })
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/pty*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_review_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
     }),
   )
-  await page.route("**/pty/pty_review_terminal", (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  await page.route("**/pty/pty_review_terminal*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_review_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
+    }),
+  )
+  await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: { ticket: "e2e-ticket", expires_in: 60 },
+      }),
+    }),
   )
   await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
   await page.addInitScript(() => {
@@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
   const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
   await expect(preview).toContainText("after-1")
   detailVersion = 2
+  sessionStatus[sessionID] = { type: "busy" }
   events.push(statusEvent("busy"))
   await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
   const refreshedDiff = page.waitForRequest((request) => {
@@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
       url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
     )
   })
+  sessionStatus[sessionID] = { type: "idle" }
   events.push(statusEvent("idle"))
   await refreshedDiff
   await expect(preview).toContainText("after-2")

+ 2 - 2
packages/app/e2e/regression/session-list-path-loading.spec.ts

@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
   const pathBlocked = new Promise<void>((resolve) => {
     releasePath = resolve
   })
-  await page.route("**/path?*", async (route) => {
-    if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
+  await page.route("**/api/path?*", async (route) => {
+    if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
     await pathBlocked
     return route.fallback()
   })

+ 8 - 4
packages/app/e2e/regression/session-request-docks.spec.ts

@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
   const rejectRequests: string[] = []
   page.on("request", (request) => {
     if (request.method() !== "POST") return
-    if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
+    if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
+      rejectRequests.push(request.url())
   })
 
   await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
 
   await question.getByRole("radio", { name: /Minimal/ }).click()
   const reply = page.waitForRequest(
-    (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
+    (request) =>
+      request.method() === "POST" &&
+      new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
   )
   await question.getByRole("button", { name: "Submit" }).click()
   expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
   const reply = page.waitForRequest((request) => request.method() === "POST")
   await permission.getByRole("button", { name: "Allow once" }).click()
   const request = await reply
-  expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
-  expect(request.postDataJSON()).toEqual({ response: "once" })
+  expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
+  expect(request.postDataJSON()).toEqual({ reply: "once" })
 })
 
 test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -170,6 +173,7 @@ async function mockServer(
   },
 ) {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,

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

@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
       assistantMessage([
         toolPart(
           id,
-          "patch",
+          "apply_patch",
           "completed",
           { files: ["src/a.ts"] },
           {

+ 1 - 1
packages/app/e2e/regression/session-timeline-file-state.spec.ts

@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
       assistantMessage([
         toolPart(
           patchID,
-          "patch",
+          "apply_patch",
           "completed",
           { files: files.map((file) => file.filePath) },
           { metadata: { files } },

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

@@ -35,6 +35,7 @@ test.describe("session timeline projection", () => {
       editPart("prt_edit"),
       toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
       patchPart("prt_patch"),
+      toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
       toolPart(
         "prt_question",
         "question",
@@ -64,6 +65,7 @@ test.describe("session timeline projection", () => {
     ]) {
       await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
     }
+    await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
   })
 
   test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
@@ -247,7 +249,7 @@ function editPart(id: string) {
 function patchPart(id: string) {
   return toolPart(
     id,
-    "patch",
+    "apply_patch",
     "completed",
     { files: ["src/a.ts", "src/b.ts"] },
     {

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

@@ -8,7 +8,7 @@ import {
 } from "../performance/timeline-stability/fixture"
 
 test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
-  const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
+  const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
   const parts = ordinary.map((tool, index) =>
     toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
   )
@@ -17,11 +17,13 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
       error: "The user dismissed this question",
     }),
     toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
+    toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
   )
   await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
 
   await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
   await expect(page.getByText(/dismissed/i)).toBeVisible()
+  await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
   for (let index = 0; index < ordinary.length; index++) {
     await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
   }
@@ -88,7 +90,7 @@ function questionInput() {
 function errorInput(tool: string) {
   if (tool === "bash") return { command: "exit 1" }
   if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
-  if (tool === "patch") return { files: ["src/error.ts"] }
+  if (tool === "apply_patch") return { files: ["src/error.ts"] }
   if (tool === "webfetch") return { url: "https://example.com" }
   if (tool === "websearch") return { query: "failure" }
   if (tool === "task") return { description: "Fail task", subagent_type: "explore" }

+ 190 - 0
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts

@@ -0,0 +1,190 @@
+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/TodoDockNavigation"
+const projectID = "proj_todo_dock_navigation"
+const sourceID = "ses_todo_dock_source"
+const otherID = "ses_todo_dock_other"
+const sourceTitle = "Todo dock animation"
+const otherTitle = "Separate session"
+
+const activeTodos = [
+  { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
+  { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
+  { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
+]
+
+type EventPayload = {
+  directory: string
+  payload: Record<string, unknown>
+}
+
+test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
+
+test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
+  test.setTimeout(90_000)
+  const events: EventPayload[] = []
+  const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
+  const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
+
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "todo-dock-navigation",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    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(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
+    sessionStatus: { [sourceID]: { type: "busy" } },
+    pageMessages: () => ({ items: [] }),
+    events: () => events.splice(0, 1),
+    eventRetry: 16,
+    sessionStatus: () => sessionStatus,
+    todos: (sessionID) => todos[sessionID] ?? [],
+  })
+  await configurePage(page)
+
+  await page.goto(sessionHref(sourceID))
+  await expectSessionTitle(page, sourceTitle)
+  const dock = page.locator('[data-component="session-todo-dock"]')
+  await expect(dock).toHaveCount(0)
+
+  sessionStatus[sourceID] = { type: "busy" }
+  events.push(statusEvent(sourceID, "busy"))
+  await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
+
+  await page.waitForTimeout(700)
+  const opening = sampleDock(page, 1_000)
+  todos[sourceID] = activeTodos
+  events.push(todoEvent(sourceID, activeTodos))
+  await expect(dock).toBeVisible()
+  await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
+  expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
+
+  await switchSession(page, otherID, otherTitle)
+  await expect(dock).toHaveCount(0)
+
+  const returningOpen = sampleDock(page, 700)
+  await switchSession(page, sourceID, sourceTitle)
+  const openSamples = (await returningOpen).filter((sample) => sample.present)
+  expect(openSamples.length).toBeGreaterThan(0)
+  expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
+  expect(openSamples[0]!.height).toBeGreaterThan(70)
+  await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
+
+  const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
+  const closing = sampleDock(page, 1_000)
+  todos[sourceID] = completedTodos
+  events.push(todoEvent(sourceID, completedTodos))
+  await expect(dock).toHaveCount(0)
+  expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
+  todos[sourceID] = []
+  events.push(todoEvent(sourceID, []))
+
+  await switchSession(page, otherID, otherTitle)
+  const returningEmpty = sampleDock(page, 700)
+  await switchSession(page, sourceID, sourceTitle)
+  await expect(dock).toHaveCount(0)
+  expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
+})
+
+function session(id: string, title: string, created: number) {
+  return {
+    id,
+    slug: id,
+    projectID,
+    directory,
+    title,
+    version: "dev",
+    time: { created, updated: created },
+  }
+}
+
+function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
+  return {
+    directory,
+    payload: { type: "session.status", properties: { sessionID, status: { type } } },
+  }
+}
+
+function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
+  return {
+    directory,
+    payload: { type: "todo.updated", properties: { sessionID, todos: next } },
+  }
+}
+
+async function configurePage(page: Page) {
+  const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+  await page.addInitScript(
+    ({ directory, dirBase64, server, sessionIDs }) => {
+      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(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
+      )
+    },
+    { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
+  )
+}
+
+function sessionHref(sessionID: string) {
+  const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+  return `/server/${base64Encode(server)}/session/${sessionID}`
+}
+
+async function switchSession(page: Page, sessionID: string, title: string) {
+  const href = sessionHref(sessionID)
+  const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
+  await expect(tab).toBeVisible()
+  await tab.click()
+  await expectSessionTitle(page, title)
+}
+
+function sampleDock(page: Page, duration: number) {
+  return page.evaluate(async (duration) => {
+    const samples: { present: boolean; height: number; opacity: number }[] = []
+    const start = performance.now()
+    while (performance.now() - start < duration) {
+      const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
+      const clip = dock?.parentElement?.parentElement
+      const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
+      samples.push({
+        present: !!dock,
+        height: clip?.getBoundingClientRect().height ?? 0,
+        opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
+      })
+      await new Promise(requestAnimationFrame)
+    }
+    return samples
+  }, duration)
+}

+ 7 - 4
packages/app/e2e/regression/subagent-child-navigation.spec.ts

@@ -1,6 +1,6 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { expect, test, type Page } from "@playwright/test"
-import { mockOpenCodeServer } from "../utils/mock-server"
+import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
 import { expectSessionTitle } from "../utils/waits"
 
 const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
     events,
     eventRetry: events ? 16 : undefined,
   })
-  // The child session resolves via /session/:id but is absent from the /session list,
+  // The child session resolves by ID but is absent from the session list,
   // matching a subagent session that has not been loaded into the list cache yet.
   await page.route(
-    (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
+    (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
     (route) =>
       route.fulfill({
         status: 200,
         contentType: "application/json",
         headers: { "access-control-allow-origin": "*" },
-        body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
+        body: JSON.stringify({
+          data: [currentSession(session(parentID, parentTitle, 1700000000000))],
+          cursor: {},
+        }),
       }),
   )
   await configurePage(page)

+ 57 - 6
packages/app/e2e/regression/tab-navigate-mousedown.spec.ts

@@ -1,9 +1,12 @@
 import { expect, test, type Page, type Route } from "@playwright/test"
 import { base64Encode } from "@opencode-ai/core/util/encode"
+import { currentSession } from "../utils/mock-server"
 
 const server = "http://127.0.0.1:4096"
 const sessionA = session("ses_tab_a", "Tab A session")
 const sessionB = session("ses_tab_b", "Tab B session")
+const sessionC = session("ses_tab_c", "Tab C session")
+const unresolvedSessionID = "ses_tab_unresolved"
 
 test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
   await mockServer(page)
@@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
   await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
 })
 
+test("keyboard navigation follows the visible tab order", async ({ page }) => {
+  await mockServer(page)
+  await page.addInitScript(
+    ({ server, sessionA, unresolved, sessionC }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify([
+          { type: "session", server, sessionId: sessionA },
+          { type: "session", server, sessionId: unresolved },
+          { type: "session", server, sessionId: sessionC },
+        ]),
+      )
+    },
+    { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
+  )
+
+  const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
+  const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
+  await page.goto(hrefA)
+  await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
+  await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
+
+  await page.keyboard.press("Control+Alt+ArrowRight")
+
+  await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
+})
+
 function session(id: string, title: string) {
   return {
     id,
@@ -52,22 +83,29 @@ function session(id: string, title: string) {
 }
 
 async function mockServer(page: Page) {
-  const sessions = [sessionA, sessionB]
+  const sessions = [sessionA, sessionB, sessionC]
   await page.route("**/*", async (route) => {
     const url = new URL(route.request().url())
     if (url.origin !== server) return route.fallback()
-    if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
+    if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
+      return new Promise(() => {})
+    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+      return sse(route)
     if (url.pathname === "/global/health") return json(route, { healthy: true })
-    if (url.pathname === "/session") return json(route, sessions)
+    if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
+    if (url.pathname === "/api/session/active") return json(route, { data: {} })
+    const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
+    if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
+    if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
+      return json(route, { data: [], cursor: {} })
     const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
     if (byId) return json(route, byId)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
     if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
-    if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
+    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
     if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
       return json(route, [])
-    if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].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, { all: [], connected: [], default: { providerID: "", modelID: "" } })
     if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -89,7 +127,20 @@ async function mockServer(page: Page) {
         directory: sessionA.directory,
         home: sessionA.directory,
       })
+    if (url.pathname === "/api/path")
+      return json(route, {
+        state: sessionA.directory,
+        config: sessionA.directory,
+        worktree: sessionA.directory,
+        directory: sessionA.directory,
+        home: sessionA.directory,
+      })
     if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    if (url.pathname === "/api/vcs")
+      return json(route, {
+        location: { directory: sessionA.directory },
+        data: { branch: "main", defaultBranch: "main" },
+      })
     return json(route, {})
   })
 }

+ 36 - 18
packages/app/e2e/regression/terminal-composer-focus.spec.ts

@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
 
 test.beforeEach(async ({ page }) => {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
     ],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) => {
+    expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
+    })
+  })
+  await page.route(`**/api/pty/${ptyID}*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
     }),
   )
-  await page.route(`**/pty/${ptyID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
-  )
-  await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
+  await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
       headers: { "access-control-allow-origin": "*" },
-      body: JSON.stringify({ ticket: "e2e-ticket" }),
+      body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
     }),
   )
-  await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
+  await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
   })
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
   const ghostty = Promise.withResolvers<void>()
   const release = Promise.withResolvers<void>()
   const created = { count: 0 }
-  await page.route("**/pty", (route) => {
+  await page.route("**/api/pty*", (route) => {
     created.count += 1
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
     })
   })
   await page.route(/ghostty-web/, async (route) => {
@@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
 
 test("focuses a terminal created from the new-terminal button", async ({ page }) => {
   const created = { count: 0 }
-  await page.route("**/pty", (route) => {
+  await page.route("**/api/pty*", (route) => {
     created.count += 1
-    const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
+    const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(next),
+      body: JSON.stringify({ location: ptyLocation(), data: next }),
     })
   })
-  await page.route(`**/pty/${newPtyID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  await page.route(`**/api/pty/${newPtyID}*`, (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
+    }),
   )
-  await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
+  await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
       headers: { "access-control-allow-origin": "*" },
-      body: JSON.stringify({ ticket: "e2e-ticket" }),
+      body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
     }),
   )
-  await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
+  await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, "Terminal composer focus")
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
     { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
   )
 }
+
+function ptyLocation() {
+  return { directory, project: { id: projectID, directory } }
+}
+
+function ptyInfo(id: string, title: string) {
+  return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
+}

+ 42 - 5
packages/app/e2e/regression/terminal-hidden.spec.ts

@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
 test("unmounts the terminal panel while it is hidden", async ({ page }) => {
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
     ],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_hidden_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
     }),
   )
-  await page.route("**/pty/pty_hidden_terminal", (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_hidden_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
+    }),
+  )
+  await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: { ticket: "e2e-ticket", expires_in: 60 },
+      }),
+    }),
   )
-  await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
+  await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, title)

+ 29 - 9
packages/app/e2e/regression/terminal-tab-switch.spec.ts

@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
   const terminal = page.locator('[data-component="terminal"]')
   await expect(terminal).toBeVisible()
   await expect.poll(() => connections.length).toBe(1)
+  const connection = new URL(connections[0]!)
+  expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
+  expect(connection.searchParams.get("location[directory]")).toBe(directory)
+  expect(connection.searchParams.get("ticket")).toBeNull()
   await writeProbe(page)
 
   await switchTab(page, titleB)
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
 
 async function setup(page: Page) {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -85,26 +90,33 @@ async function setup(page: Page) {
     sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
     }),
   )
-  await page.route(`**/pty/${ptyID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
-  )
-  await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
+  await page.route(`**/api/pty/${ptyID}*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      headers: { "access-control-allow-origin": "*" },
-      body: JSON.stringify({ ticket: "e2e-ticket" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
     }),
   )
+  await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
+    expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
+    const url = new URL(route.request().url())
+    expect(url.searchParams.get("location[directory]")).toBe(directory)
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      headers: { "access-control-allow-origin": "*" },
+      body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
+    })
+  })
   const connections: string[] = []
-  await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
+  await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
     connections.push(ws.url())
   })
 
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
 function sessionHref(sessionID: string) {
   return `/server/${base64Encode(server)}/session/${sessionID}`
 }
+
+function ptyLocation() {
+  return { directory, project: { id: projectID, directory } }
+}
+
+function ptyInfo() {
+  return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
+}

+ 3 - 2
packages/app/e2e/smoke/session-timeline.fixture.ts

@@ -120,7 +120,7 @@ function toolPart(
   outputLength = 160,
 ): MessagePart {
   const metadata =
-    tool === "patch"
+    tool === "apply_patch"
       ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
       : tool === "edit" || tool === "write"
         ? {
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
       ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
       : []),
     ...(index % 8 === 0
-      ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
+      ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
       : []),
     ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
     ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
@@ -229,6 +229,7 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
 ]).flat()
 
 function renderable(part: MessagePart) {
+  if (part.type === "tool" && part.tool === "todowrite") return false
   if (part.type === "text") return !!part.text.trim()
   if (part.type === "reasoning") return !!part.text.trim()
   return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"

+ 97 - 0
packages/app/e2e/user-story/model-selection-flow.spec.ts

@@ -0,0 +1,97 @@
+import { expect, test } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible } from "../utils/waits"
+
+const directory = "C:/OpenCode/NewProject"
+
+test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
+  let connectedGo = false
+  let pendingGo = false
+  const connections: Array<{ integrationID: string; body: unknown }> = []
+
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: "proj_model_selection_flow",
+      worktree: directory,
+      vcs: "git",
+      name: "NewProject",
+      time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
+      sandboxes: [],
+    },
+    provider: () => ({
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: {
+            "free-model": {
+              id: "free-model",
+              name: "Free Model",
+              cost: { input: 0, output: 0 },
+              limit: { context: 200_000 },
+            },
+          },
+        },
+        {
+          id: "opencode-go",
+          name: "OpenCode Go",
+          models: {
+            "go-model-1": {
+              id: "go-model-1",
+              name: "Go Model 1",
+              cost: { input: 1, output: 1 },
+              limit: { context: 200_000 },
+            },
+          },
+        },
+      ],
+      connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
+      default: { providerID: "opencode", modelID: "free-model" },
+    }),
+    integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
+    onConnectKey: (input) => {
+      connections.push(input)
+      if (input.integrationID === "opencode-go") pendingGo = true
+    },
+    onInstanceDispose: () => {
+      if (pendingGo) connectedGo = true
+    },
+    sessions: [],
+    pageMessages: () => ({ items: [] }),
+    fileList: (path) =>
+      path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
+    findFiles: () => ["NewProject"],
+  })
+  await page.addInitScript(() => {
+    localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+    localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
+  })
+
+  await page.goto("/")
+  const addProject = page.locator('[data-action="home-add-project-row"]')
+  await expectAppVisible(addProject)
+  await addProject.click()
+  await page.locator("[data-directory-path]").click()
+
+  await page.locator('[data-action="home-new-session"]').click()
+  await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
+
+  const modelControl = page.locator('[data-action="prompt-model"]')
+  await modelControl.click()
+  await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
+
+  await page.locator('[data-provider-id="opencode-go"]').click()
+  await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
+  await page.locator('[data-action="provider-connect-submit"]').click()
+  await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
+  expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
+
+  await expect(modelControl).toHaveAttribute("data-control-type", "popover")
+  await modelControl.click()
+  const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
+  await expect(goModel).toBeVisible()
+  await goModel.click()
+
+  await expect(modelControl).toContainText("Go Model 1")
+})

+ 41 - 6
packages/app/e2e/utils/mock-server.ts

@@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
 
 export interface MockServerConfig {
   protocol?: "v1" | "v2"
-  provider: unknown
+  provider: unknown | (() => unknown)
+  integrationMethods?: Record<string, unknown[]>
+  onConnectKey?: (input: { integrationID: string; body: unknown }) => void
+  onInstanceDispose?: () => void
   directory: string
   project: unknown
   sessions: ({ id: string } & Record<string, unknown>)[]
@@ -18,19 +21,19 @@ export interface MockServerConfig {
   onMessage?: (input: { sessionID: string; messageID: string }) => void
   events?: () => unknown[]
   eventRetry?: number
+  todos?: (sessionID: string) => unknown[]
   permissions?: unknown[] | (() => unknown[])
   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
+  sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
 }
 
 export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
   const cursors = new Map<string, string>()
   let nextCursor = 0
   const staticRoutes: Record<string, unknown> = {
-    "/provider": config.provider,
     "/path": {
       state: config.directory,
       config: config.directory,
@@ -60,7 +63,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
         route,
         path === "/api/event"
           ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
-          : events,
+          : [
+              ...(path === "/global/event"
+                ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
+                : []),
+              ...(events ?? []),
+            ],
         config.eventRetry,
       )
     }
@@ -69,11 +77,27 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (path === "/api/health" && config.protocol === "v2")
       return json(route, { healthy: true, version: "2.0.0", pid: 1 })
     if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
+    if (path === "/provider")
+      return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
+    if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
+    const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
+    if (legacyAuth && route.request().method() === "PUT") {
+      config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
+      return json(route, true)
+    }
+    if (path === "/instance/dispose" && route.request().method() === "POST") {
+      config.onInstanceDispose?.()
+      return json(route, true)
+    }
     if (path === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
       return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
-    if (path === "/session/status") return json(route, config.sessionStatus ?? {})
+    if (path === "/session/status")
+      return json(
+        route,
+        typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
+      )
     if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
     if (path === "/file" && config.fileList)
       return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
@@ -120,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
         location: location(config),
         data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
       })
-    if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
+    const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
+    if (integrationConnect && route.request().method() === "POST") {
+      config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
       return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
     if (path === "/api/project") return json(route, [config.project])
     if (path === "/api/project/current")
       return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
@@ -199,6 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
       return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
     }
+    if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
+      return json(route, true)
+    }
+    if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
+      return json(route, true)
+    }
     if (
       /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
       route.request().method() === "POST"
@@ -237,6 +270,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       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, [])
 
     const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)

+ 8 - 0
packages/app/e2e/utils/sse-transport.ts

@@ -197,6 +197,14 @@ export async function installSseTransport<T>(
               controller.enqueue(
                 encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
               )
+            if (url.pathname === "/global/event")
+              controller.enqueue(
+                encoder.encode(
+                  frame({
+                    payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
+                  }),
+                ),
+              )
             request.signal.addEventListener(
               "abort",
               () => {

+ 3 - 3
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.18.4",
+  "version": "1.18.8",
   "description": "",
   "type": "module",
   "exports": {
@@ -53,10 +53,10 @@
     "@dnd-kit/helpers": "0.5.0",
     "@dnd-kit/solid": "0.5.0",
     "@kobalte/core": "catalog:",
-    "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
+    "@opencode-ai/client": "workspace:*",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/schema": "workspace:*",
-    "@opencode-ai/sdk": "1.18.5",
+    "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
     "@opencode-ai/session-ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@pierre/trees": "1.0.0-beta.4",

+ 26 - 1
packages/app/src/app.tsx

@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
 import { createSessionLineage } from "@/pages/session/session-lineage"
 
 import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
-import { NewHome, LegacyHome } from "@/pages/home"
+import { NewHome } from "@/pages/home"
+import { LegacyHome } from "@/pages/home/legacy-home"
 
 const NewSession = lazy(() => import("@/pages/new-session"))
 
@@ -236,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
   return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
 }
 
+function LayoutCompatibility(props: ParentProps) {
+  const global = useGlobal()
+  const navigate = useNavigate()
+  const server = useServer()
+  const settings = useSettings()
+
+  createEffect(() => {
+    if (settings.general.newLayoutDesigns()) return
+    const current = server.current
+    if (!current) return
+    const protocol = global.ensureServerCtx(current).sdk.protocolKind()
+    if (protocol !== "v2") return
+    const next = global.servers.list().find((s) => {
+      if (ServerConnection.key(s) === ServerConnection.key(current)) return false
+      return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
+    })
+    if (!next) return
+    navigate("/")
+    queueMicrotask(() => server.setActive(ServerConnection.key(next)))
+  })
+
+  return <>{props.children}</>
+}
+
 declare global {
   interface Window {
     __OPENCODE__?: {

+ 8 - 6
packages/app/src/components/command-palette.ts

@@ -1,5 +1,6 @@
 import { getFilename } from "@opencode-ai/core/util/path"
-import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
+import type { Project } from "@opencode-ai/sdk/v2/client"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { createMemo, onCleanup } from "solid-js"
 import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
@@ -13,6 +14,7 @@ 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"
+import { normalizeSessionInfo } from "@/utils/session"
 
 export type CommandPaletteEntry = {
   id: string
@@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
     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 }),
+    load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
     untitled: () => language.t("command.session.new"),
     category: () => language.t("command.category.session"),
   })
@@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
   server: ServerConnection.Key
   opened: () => LocalProject[]
   stored: () => Project[]
-  load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
+  load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
   untitled: () => string
   category: () => string
 }) {
@@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
     return props
       .load(search, current.signal)
       .then((result) =>
-        (result.data ?? [])
+        result.data
+          .map(normalizeSessionInfo)
           .filter((session) => !session.time.archived)
           .map((session) => {
             const project =
@@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
               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),
+              description: project ? displayName(project) : getFilename(session.directory),
               category: props.category(),
               directory: session.directory,
               sessionID: session.id,

+ 1 - 2
packages/app/src/components/dialog-command-palette-v2.tsx

@@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
     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 }),
+    load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
     untitled: () => language.t("command.session.new"),
     category: () => language.t("command.category.session"),
   })

+ 89 - 113
packages/app/src/components/dialog-connect-provider.tsx

@@ -1,4 +1,4 @@
-import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
+import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -28,6 +28,7 @@ import {
   Switch,
 } from "solid-js"
 import { createStore, produce } from "solid-js/store"
+import { useParams } from "@solidjs/router"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
@@ -35,8 +36,10 @@ import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { CustomProviderForm } from "./dialog-custom-provider"
+import { decode64 } from "@/utils/base64"
 
 const CUSTOM_ID = "_custom"
+type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
 
 export function useProviderConnectController(options: { onBack?: () => void } = {}) {
   const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -154,7 +157,7 @@ function ProviderPicker(props: {
   const settings = useSettings()
   if (settings.general.newLayoutDesigns())
     return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
-  const providers = useProviders(props.directory)
+  const providers = useProviders(() => props.directory?.())
   const language = useLanguage()
   const popularGroup = () => language.t("dialog.provider.group.popular")
   const otherGroup = () => language.t("dialog.provider.group.other")
@@ -226,10 +229,8 @@ function ProviderPickerV2(props: {
   onSelect: (provider: string) => void
   onPrepare?: () => void
 }) {
-  const providers = useProviders(props.directory)
+  const providers = useProviders(() => props.directory?.())
   const language = useLanguage()
-  const serverSync = useServerSync()
-  const serverSDK = useServerSDK()
   const [store, setStore] = createStore({
     filter: "",
     active: undefined as string | undefined,
@@ -266,19 +267,7 @@ function ProviderPickerV2(props: {
 
   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))
+    props.onSelect(provider)
   }
 
   const move = (event: KeyboardEvent, direction: number) => {
@@ -395,10 +384,16 @@ function ProviderConnection(props: {
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
+  const params = useParams()
   const language = useLanguage()
   const settings = useSettings()
   const newLayout = settings.general.newLayoutDesigns
-  const providers = useProviders(props.directory)
+  const providers = useProviders(() => props.directory?.())
+  const directory = () => props.directory?.() ?? decode64(params.dir)
+  const location = () => {
+    const value = directory()
+    return value ? { directory: value } : undefined
+  }
 
   const alive = { value: true }
   const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -413,38 +408,34 @@ function ProviderConnection(props: {
   const provider = createMemo(
     () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
   )
-  const fallback = createMemo<ProviderAuthMethod[]>(() => [
+  const fallback = createMemo<ConnectMethod[]>(() => [
     {
-      type: "api" as const,
+      type: "key" as const,
       label: language.t("provider.connect.method.apiKey"),
     },
   ])
-  const [auth] = createResource(
-    () => props.provider,
-    async () => {
-      const cached = serverSync().data.provider_auth[props.provider]
-      if (cached) return cached
-      const res = await serverSDK().client.provider.auth()
-      if (!alive.value) return fallback()
-      serverSync().set("provider_auth", res.data ?? {})
-      return res.data?.[props.provider] ?? fallback()
-    },
+  const [integration] = createResource(
+    () => ({ provider: props.provider, directory: directory() }),
+    (input) =>
+      serverSDK()
+        .api.integration.get({
+          integrationID: input.provider,
+          location: input.directory ? { directory: input.directory } : undefined,
+        })
+        .then((result) => result.data),
   )
-  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 loading = createMemo(() => integration.loading)
+  const methods = createMemo<ConnectMethod[]>(() => {
+    const values = integration.latest?.methods.filter(
+      (method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
+    )
+    return values?.length ? values : fallback()
+  })
   const [store, setStore] = createStore({
-    methodIndex: directMethod as undefined | number,
-    authorization: undefined as undefined | ProviderAuthAuthorization,
+    methodIndex: undefined as undefined | number,
+    authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
     promptInputs: undefined as undefined | Record<string, string>,
-    state: (directMethod === undefined ? "pending" : undefined) as
-      | undefined
-      | "pending"
-      | "complete"
-      | "error"
-      | "prompt",
+    state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
     error: undefined as string | undefined,
   })
 
@@ -454,7 +445,7 @@ function ProviderConnection(props: {
     | { type: "auth.prompt" }
     | { type: "auth.inputs"; inputs: Record<string, string> }
     | { type: "auth.pending" }
-    | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
+    | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
     | { type: "auth.error"; error: string }
 
   function dispatch(action: Action) {
@@ -508,7 +499,7 @@ function ProviderConnection(props: {
 
   const methodLabel = (value?: { type?: string; label?: string }) => {
     if (!value) return ""
-    if (value.type === "api") return language.t("provider.connect.method.apiKey")
+    if (value.type === "key") return language.t("provider.connect.method.apiKey")
     return value.label ?? ""
   }
 
@@ -518,7 +509,7 @@ function ProviderConnection(props: {
     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,
+      hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
     }
   }
 
@@ -549,46 +540,22 @@ function ProviderConnection(props: {
     const method = methods()[index]
     dispatch({ type: "method.select", index })
 
-    if (method.type === "api" && method.prompts?.length) {
-      if (!inputs) {
-        dispatch({ type: "auth.prompt" })
-        return
-      }
-      dispatch({ type: "auth.inputs", inputs })
-      return
-    }
-
     if (method.type === "oauth") {
       if (method.prompts?.length && !inputs) {
         dispatch({ type: "auth.prompt" })
         return
       }
       dispatch({ type: "auth.pending" })
-      const start = Date.now()
       await serverSDK()
-        .client.provider.oauth.authorize(
-          {
-            providerID: props.provider,
-            method: index,
-            inputs,
-          },
-          { throwOnError: true },
-        )
+        .api.integration.oauth.connect({
+          integrationID: props.provider,
+          methodID: method.id,
+          inputs: inputs ?? {},
+          location: location(),
+        })
         .then((x) => {
           if (!alive.value) return
-          const elapsed = Date.now() - start
-          const delay = 1000 - elapsed
-
-          if (delay > 0) {
-            if (timer.current !== undefined) clearTimeout(timer.current)
-            timer.current = setTimeout(() => {
-              timer.current = undefined
-              if (!alive.value) return
-              dispatch({ type: "auth.complete", authorization: x.data! })
-            }, delay)
-            return
-          }
-          dispatch({ type: "auth.complete", authorization: x.data! })
+          dispatch({ type: "auth.complete", authorization: x.data })
         })
         .catch((e) => {
           if (!alive.value) return
@@ -603,9 +570,9 @@ function ProviderConnection(props: {
       index: 0,
     })
 
-    const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
+    const prompts = createMemo(() => {
       const value = method()
-      return value?.prompts ?? []
+      return value?.type === "oauth" ? (value.prompts ?? []) : []
     })
     const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
       if (!prompt.when) return true
@@ -636,10 +603,6 @@ function ProviderConnection(props: {
         setFormStore("index", next)
         return
       }
-      if (method()?.type === "api") {
-        dispatch({ type: "auth.inputs", inputs: value })
-        return
-      }
       await selectMethod(store.methodIndex, value)
     }
 
@@ -741,7 +704,9 @@ function ProviderConnection(props: {
   })
 
   async function complete() {
-    await serverSDK().client.global.dispose()
+    await serverSync()
+      .refreshProviders()
+      .catch(() => undefined)
     dialog.close()
     showToast({
       variant: "success",
@@ -805,7 +770,7 @@ function ProviderConnection(props: {
               listRef = ref
             }}
             items={methods}
-            key={(m) => m?.label}
+            key={(m) => m?.label ?? m?.type}
             onSelect={async (selected, index) => {
               if (!selected) return
               void selectMethod(index)
@@ -851,13 +816,10 @@ function ProviderConnection(props: {
       }
 
       setFormStore("error", undefined)
-      await serverSDK().client.auth.set({
-        providerID: props.provider,
-        auth: {
-          type: "api",
-          key: apiKey,
-          ...(store.promptInputs ? { metadata: store.promptInputs } : {}),
-        },
+      await serverSDK().api.integration.connect.key({
+        integrationID: props.provider,
+        location: location(),
+        key: apiKey,
       })
       await complete()
     }
@@ -891,6 +853,7 @@ function ProviderConnection(props: {
                 ref={apiKey}
                 class="!w-full"
                 name="apiKey"
+                data-input="provider-api-key"
                 placeholder={language.t("provider.connect.apiKey.placeholder")}
                 value={formStore.value}
                 invalid={formStore.error !== undefined}
@@ -907,7 +870,7 @@ function ProviderConnection(props: {
                 </div>
               )}
             </Show>
-            <ButtonV2 type="submit" variant="contrast">
+            <ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
               {language.t("common.continue")}
             </ButtonV2>
           </form>
@@ -984,12 +947,13 @@ function ProviderConnection(props: {
 
       setFormStore("error", undefined)
       const result = await serverSDK()
-        .client.provider.oauth.callback({
-          providerID: props.provider,
-          method: store.methodIndex,
+        .api.integration.oauth.complete({
+          integrationID: props.provider,
+          attemptID: store.authorization!.attemptID,
+          location: location(),
           code,
         })
-        .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
+        .then(() => ({ ok: true as const }))
         .catch((error) => ({ ok: false as const, error }))
       if (result.ok) {
         await complete()
@@ -1076,25 +1040,37 @@ function ProviderConnection(props: {
     })
 
     onMount(() => {
-      void (async () => {
+      const poll = async () => {
+        const authorization = store.authorization
+        if (!authorization || !alive.value) return
         const result = await serverSDK()
-          .client.provider.oauth.callback({
-            providerID: props.provider,
-            method: store.methodIndex,
+          .api.integration.oauth.status({
+            integrationID: props.provider,
+            attemptID: authorization.attemptID,
+            location: location(),
           })
-          .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
+          .then((value) => ({ ok: true as const, status: value.data }))
           .catch((error) => ({ ok: false as const, error }))
-
         if (!alive.value) return
-
         if (!result.ok) {
-          const message = formatError(result.error, language.t("common.requestFailed"))
-          dispatch({ type: "auth.error", error: message })
+          dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
           return
         }
-
-        await complete()
-      })()
+        if (result.status.status === "complete") {
+          await complete()
+          return
+        }
+        if (result.status.status === "failed") {
+          dispatch({ type: "auth.error", error: result.status.message })
+          return
+        }
+        if (result.status.status === "expired") {
+          dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
+          return
+        }
+        timer.current = setTimeout(poll, 1_000)
+      }
+      void poll()
     })
 
     return (
@@ -1178,15 +1154,15 @@ function ProviderConnection(props: {
                 </div>
               </div>
             </Match>
-            <Match when={method()?.type === "api"}>
+            <Match when={method()?.type === "key"}>
               <ApiAuthView />
             </Match>
             <Match when={method()?.type === "oauth"}>
               <Switch>
-                <Match when={store.authorization?.method === "code"}>
+                <Match when={store.authorization?.mode === "code"}>
                   <OAuthCodeView />
                 </Match>
-                <Match when={store.authorization?.method === "auto"}>
+                <Match when={store.authorization?.mode === "auto"}>
                   <OAuthAutoView />
                 </Match>
               </Switch>

+ 2 - 1
packages/app/src/components/dialog-custom-provider.tsx

@@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
 
   const saveMutation = useMutation(() => ({
     mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
+      if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
       const disabledProviders = serverSync().data.config.disabled_providers ?? []
       const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
 
@@ -177,7 +178,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
   return (
     <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="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
+        <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>
 

+ 3 - 7
packages/app/src/components/dialog-fork.tsx

@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
     const dir = base64Encode(sdk().directory)
 
     sdk()
-      .client.session.fork({ sessionID, messageID: item.id })
+      .api.session.fork({ sessionID, messageID: item.id })
       .then((forked) => {
-        if (!forked.data) {
-          showToast({ title: language.t("common.requestFailed") })
-          return
-        }
         dialog.close()
-        prompt.set(restored, undefined, { dir, id: forked.data.id })
-        navigate(`/${dir}/session/${forked.data.id}`)
+        prompt.set(restored, undefined, { dir, id: forked.id })
+        navigate(`/${dir}/session/${forked.id}`)
       })
       .catch((err: unknown) => {
         const message = err instanceof Error ? err.message : String(err)

+ 30 - 12
packages/app/src/components/dialog-select-directory-v2.tsx

@@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
 import { useGlobal } from "@/context/global"
 import { useLanguage } from "@/context/language"
 import { ServerConnection } from "@/context/server"
+import type { Path } from "@opencode-ai/sdk/v2/client"
 import {
   absoluteTreePath,
   activeTreeNavigation,
@@ -28,6 +29,7 @@ import {
 } from "./directory-picker-domain"
 import "./dialog-select-directory-v2.css"
 import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
+import { getFilename } from "@opencode-ai/core/util/path"
 
 interface DialogSelectDirectoryV2Props {
   title?: string
@@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
   const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
   const [fallbackPath] = createResource(
     () => (missingBase() ? true : undefined),
-    () =>
-      sdk.client.path
+    async (): Promise<Path | undefined> => {
+      if ((await sdk.protocol) !== "v1") return
+      return sdk.client.path
         .get()
         .then((result) => result.data)
-        .catch(() => undefined),
+        .catch(() => undefined)
+    },
     { initialValue: undefined },
   )
   const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
   )
   const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
   const [suggestions] = createResource(input, async (value) => {
-    const typed = cleanPickerInput(value).replace(/\/+$/, "")
+    const cleaned = cleanPickerInput(value)
+    const typed = cleaned.replace(/\/+$/, "")
     const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
-    if (!typed || typed === current) return { query: value, items: [] }
+    if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
     const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
     if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
-    const files = await sdk.client.find
-      .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
-      .then((result) => result.data ?? [])
+    const base = pickerRoot(cleaned) || root() || start()
+    if (!base) return { query: value, items: directories.slice(0, 5) }
+    const files = await sdk.api.file
+      .find({
+        location: { directory: base },
+        query: pickerFileSearchQuery(base, value, home()),
+        type: "file",
+        limit: 20,
+      })
+      .then((result) => result.data)
       .catch(() => [])
     const results = [
       ...directories,
-      ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
+      ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
     ]
     return {
       query: value,
@@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
       existing ??
       loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
         if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
-        return sdk.client.file
-          .list({ directory: absolute, path: "" })
-          .then((result) => result.data ?? [])
+        return sdk.api.file
+          .list({ location: { directory: absolute } })
+          .then((result) =>
+            result.data.map((entry) => ({
+              name: getFilename(entry.path.replace(/[\\/]+$/, "")),
+              type: entry.type,
+            })),
+          )
           .catch(() => undefined)
       })
     listings.set(key, request)
@@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
                 {(suggestion, index) => (
                   <button
                     id={`directory-picker-v2-suggestion-${index()}`}
+                    data-directory-path={suggestion.absolute}
                     role="option"
                     aria-selected={index() === activeSuggestion()}
                     data-active={index() === activeSuggestion() ? "" : undefined}

+ 6 - 4
packages/app/src/components/dialog-select-directory.tsx

@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
 import { ServerConnection } from "@/context/server"
 import { useGlobal } from "@/context/global"
 import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
+import type { Path } from "@opencode-ai/sdk/v2/client"
 
 interface DialogSelectDirectoryProps {
   title?: string
@@ -59,10 +60,11 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
   const [fallbackPath] = createResource(
     () => (missingBase() ? true : undefined),
-    async () => {
+    async (): Promise<Path | undefined> => {
+      if ((await sdk.protocol) !== "v1") return
       return sdk.client.path
         .get()
-        .then((x) => x.data)
+        .then((result) => result.data)
         .catch(() => undefined)
     },
     { initialValue: undefined },
@@ -162,7 +164,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
           const path = displayPickerPath(item.absolute, filter(), home())
           if (path === "~") {
             return (
-              <div class="w-full flex items-center justify-between rounded-md">
+              <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
                 <div class="flex items-center gap-x-3 grow min-w-0">
                   <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
                   <div class="flex items-center text-14-regular min-w-0">
@@ -174,7 +176,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
             )
           }
           return (
-            <div class="w-full flex items-center justify-between rounded-md">
+            <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
               <div class="flex items-center gap-x-3 grow min-w-0">
                 <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
                 <div class="flex items-center text-14-regular min-w-0">

+ 2 - 2
packages/app/src/components/dialog-select-mcp.tsx

@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
         filterKeys={["name", "status"]}
         sortBy={(a, b) => a.name.localeCompare(b.name)}
         onSelect={(x) => {
-          if (!x || toggle.isPending) return
+          if (!x || x.status === "pending" || toggle.isPending) return
           toggle.mutate(x.name)
         }}
       >
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
               <div onClick={(e) => e.stopPropagation()}>
                 <Switch
                   checked={enabled()}
-                  disabled={toggle.isPending && toggle.variables === i.name}
+                  disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
                   onChange={() => {
                     if (toggle.isPending) return
                     toggle.mutate(i.name)

+ 2 - 1
packages/app/src/components/dialog-select-model-unpaid-v2.tsx

@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
       </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 data-section="free-models" 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")}
@@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
                   {(provider) => (
                     <button
                       type="button"
+                      data-provider-id={provider.id}
                       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)]":

+ 94 - 86
packages/app/src/components/dialog-select-model.tsx

@@ -1,15 +1,5 @@
 import { Popover as Kobalte } from "@kobalte/core/popover"
-import {
-  Component,
-  ComponentProps,
-  createEffect,
-  createMemo,
-  For,
-  JSX,
-  onCleanup,
-  Show,
-  ValidComponent,
-} from "solid-js"
+import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 import { decode64 } from "@/utils/base64"
 import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
+import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
 import { createEventListener } from "@solid-primitives/event-listener"
 import { matchesModelSearch } from "./dialog-select-model-search"
 
@@ -122,14 +113,13 @@ const ModelList: Component<{
 }
 
 type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
+type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
 type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
 
 export function ModelSelectorPopover(props: {
   provider?: string
   model?: ModelState
-  children?: JSX.Element
-  triggerAs?: ValidComponent
-  triggerProps?: ModelSelectorTriggerProps
+  trigger: ModelSelectorTrigger
   onClose?: (cause: "escape" | "select") => void
 }) {
   const [store, setStore] = createStore<{
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
       placement="top-start"
       gutter={4}
     >
-      <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
-        {props.children}
-      </Kobalte.Trigger>
+      <Kobalte.Trigger as={props.trigger} />
       <Kobalte.Portal>
         <Kobalte.Content
           class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
 export function ModelSelectorPopoverV2(props: {
   provider?: string
   model?: ModelState
-  children?: JSX.Element
-  triggerAs?: ValidComponent
-  triggerProps?: ModelSelectorTriggerProps
+  trigger: ModelSelectorTrigger
   onClose?: () => void
 }) {
-  const model = props.model ?? useLocal().model
-  const language = useLanguage()
   const dialog = useDialog()
-  const [store, setStore] = createStore({ open: false, search: "", active: "" })
-  let searchRef: HTMLInputElement | undefined
-  let contentRef: HTMLDivElement | undefined
-  let restoreTrigger = true
+  const controller = createModelSelectorController({
+    model: props.model,
+    provider: () => props.provider,
+    onSelect: () => props.onClose?.(),
+  })
+
+  return (
+    <ModelSelectorPopoverV2View
+      trigger={props.trigger}
+      models={controller.models}
+      groups={controller.groups}
+      current={controller.current}
+      select={controller.select}
+      onManage={() => {
+        void import("./dialog-manage-models").then((module) => {
+          void dialog.show(() => <module.DialogManageModelsV2 />)
+        })
+      }}
+      onClose={() => props.onClose?.()}
+    />
+  )
+}
 
+function createModelSelectorController(input: {
+  provider: () => string | undefined
+  model?: ModelState
+  onSelect: () => void
+}) {
+  const model = input.model ?? useLocal().model
   const allModels = createMemo(() =>
     model
       .list()
       .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
-      .filter((item) => (props.provider ? item.provider.id === props.provider : true)),
+      .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
   )
-  const models = createMemo(() => {
-    const search = store.search.trim()
-    const filtered = search
-      ? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
-      : allModels()
 
-    return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
-  })
-  const groups = createMemo(() => {
-    const byProvider = new Map<string, ModelItem[]>()
-    for (const item of models()) {
-      byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
-    }
-    return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
-  })
-  const keys = () => [...models().map(modelKey), manageKey]
-  const current = () => {
-    const value = model.current()
-    return value ? `${value.provider.id}:${value.id}` : undefined
+  return {
+    models: (search: string) => {
+      const query = search.trim()
+      const filtered = query
+        ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
+        : allModels()
+      return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
+    },
+    groups: (models: ModelItem[]) => {
+      const byProvider = new Map<string, ModelItem[]>()
+      for (const item of models) {
+        byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
+      }
+      return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
+    },
+    current: () => {
+      const value = model.current()
+      return value ? modelKey(value) : undefined
+    },
+    select: (item: ModelItem) => {
+      model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
+      input.onSelect()
+    },
   }
+}
+
+function ModelSelectorPopoverV2View(props: {
+  trigger: ModelSelectorTrigger
+  models: (search: string) => ModelItem[]
+  groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
+  current: () => string | undefined
+  select: (item: ModelItem) => void
+  onManage: () => void
+  onClose: () => void
+}) {
+  const language = useLanguage()
+  const [store, setStore] = createStore({ open: false, search: "", active: "" })
+  let searchRef: HTMLInputElement | undefined
+  let contentRef: HTMLDivElement | undefined
+  const dismiss = createMenuDismissController(() => contentRef)
+
+  const models = createMemo(() => props.models(store.search))
+  const groups = createMemo(() => props.groups(models()))
+  const keys = () => [...models().map(modelKey), manageKey]
   const initialActive = () => {
-    const selected = current()
+    const selected = props.current()
     const options = keys()
     if (selected && options.includes(selected)) return selected
     return options[0] ?? ""
   }
   const activeItem = () =>
     store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
-  const afterClose = (callback: () => void) => {
-    const complete = () => {
-      if (contentRef?.isConnected) {
-        requestAnimationFrame(complete)
-        return
-      }
-      requestAnimationFrame(() => requestAnimationFrame(callback))
-    }
-    requestAnimationFrame(complete)
-  }
   const setOpen = (open: boolean) => {
     if (open) {
-      restoreTrigger = true
+      dismiss.allowTriggerRestore()
       setStore({ open: true, active: initialActive() })
       setTimeout(() =>
         requestAnimationFrame(() => {
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
     }
     setStore({ open: false, search: "", active: "" })
   }
-  const select = (item: ModelItem) => {
-    model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
-    props.onClose?.()
-  }
   const selectModel = (item: ModelItem) => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     setOpen(false)
-    afterClose(() => select(item))
+    dismiss.afterClose(() => props.select(item))
   }
   const manage = () => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     setOpen(false)
-    afterClose(() => {
-      void import("./dialog-manage-models").then((x) => {
-        dialog.show(() => <x.DialogManageModelsV2 />)
-      })
-    })
+    dismiss.afterClose(props.onManage)
   }
   const selectActive = () => {
     const item = models().find((item) => modelKey(item) === store.active)
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
     queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
   }
   const setSearch = (value: string) => {
-    const search = value.trim()
-    const first = [...allModels()]
-      .sort((a, b) => a.name.localeCompare(b.name))
-      .find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
+    const first = props.models(value)[0]
     setStore({ search: value, active: first ? modelKey(first) : manageKey })
   }
 
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
 
   return (
     <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
-      <MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
-        {props.children}
-      </MenuV2.Trigger>
+      <MenuV2.Trigger as={props.trigger} />
       <MenuV2.Portal>
         <MenuV2.Content
-          ref={(el: HTMLDivElement) => (contentRef = el)}
+          ref={(element: HTMLDivElement) => (contentRef = element)}
           class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
-          onPointerDownOutside={() => (restoreTrigger = false)}
-          onFocusOutside={() => (restoreTrigger = false)}
-          onCloseAutoFocus={(event) => {
-            if (!restoreTrigger) event.preventDefault()
-          }}
+          onPointerDownOutside={dismiss.preventTriggerRestore}
+          onFocusOutside={dismiss.preventTriggerRestore}
+          onCloseAutoFocus={dismiss.onCloseAutoFocus}
         >
           <div class="flex flex-col p-0.5">
             <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
                   event.stopPropagation()
                   if (event.key === "Escape") {
                     event.preventDefault()
-                    restoreTrigger = false
+                    dismiss.preventTriggerRestore()
                     setOpen(false)
-                    afterClose(() => props.onClose?.())
+                    dismiss.afterClose(props.onClose)
                     return
                   }
                   if (event.altKey || event.metaKey) return
@@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: {
                       <MenuV2.GroupLabel class="gap-2 px-3">
                         <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
                       </MenuV2.GroupLabel>
-                      <MenuV2.RadioGroup value={current()}>
+                      <MenuV2.RadioGroup value={props.current()}>
                         <For each={group.items}>
                           {(item) => (
                             <TooltipV2
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
                               <MenuV2.RadioItem
                                 value={modelKey(item)}
                                 data-option-key={modelKey(item)}
-                                data-selected-model={current() === modelKey(item) ? true : undefined}
+                                data-selected-model={props.current() === modelKey(item) ? true : undefined}
                                 class="scroll-my-6 w-full"
                                 classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
                                 onMouseEnter={() => {

+ 19 - 1
packages/app/src/components/dialog-select-server.tsx

@@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
+import { detectServerProtocol } from "@/utils/server-protocol"
 import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
 import { useSettings } from "@/context/settings"
 import { useTabs } from "@/context/tabs"
@@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
         setStore("addServer", { error: language.t("dialog.server.add.error") })
         return
       }
+      if (
+        !settings.general.newLayoutDesigns() &&
+        (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
+      ) {
+        setStore("addServer", { error: language.t("dialog.server.add.error") })
+        return
+      }
 
       resetAdd()
       if (options.navigateOnAdd === false) {
@@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
         setStore("editServer", { error: language.t("dialog.server.add.error") })
         return
       }
+      if (
+        !settings.general.newLayoutDesigns() &&
+        (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
+      ) {
+        setStore("editServer", { error: language.t("dialog.server.add.error") })
+        return
+      }
       if (normalized === input.original.http.url) {
         server.add(conn)
       } else {
@@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
   )
 
   const sortedItems = createMemo(() => {
-    const list = items()
+    const raw = items()
+    const list = settings.general.newLayoutDesigns()
+      ? raw
+      : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
     if (!list.length) return list
     const active = current()
     const order = new Map(list.map((url, index) => [url, index] as const))

+ 27 - 4
packages/app/src/components/directory-picker-domain.test.ts

@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
 test("resolves directory autocomplete from the current browser root", async () => {
   const directories: string[] = []
   const sdk = {
-    client: {
-      find: {
-        files: (input: { directory: string }) => {
-          directories.push(input.directory)
+    api: {
+      file: {
+        find: (input: { location?: { directory?: string } }) => {
+          directories.push(input.location?.directory ?? "")
           return Promise.resolve({ data: [] })
         },
       },
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
   expect(directories).toEqual(["/repo", "/repo/src"])
 })
 
+test("searches from an absolute root without a default base", async () => {
+  const directories: string[] = []
+  const sdk = {
+    api: {
+      file: {
+        list: (input: { location?: { directory?: string } }) => {
+          directories.push(input.location?.directory ?? "")
+          return Promise.resolve({
+            data: [
+              { path: "Users/", type: "directory" },
+              { path: "tmp/", type: "directory" },
+            ],
+          })
+        },
+      },
+    },
+  } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
+  const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
+
+  expect(await search("/")).toEqual(["/Users", "/tmp"])
+  expect(directories).toEqual(["/"])
+})
+
 test("identifies the next directory level to preload", () => {
   expect(
     preloadTreeDirectories("src/", [

+ 13 - 10
packages/app/src/components/directory-picker-domain.ts

@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
   let current = 0
 
   const scoped = (value: string) => {
+    const raw = normalizePickerDrive(value)
+    const root = pickerRoot(raw)
+    if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
     const base = args.base()
     if (!base) return
-    const raw = normalizePickerDrive(value)
     if (!raw) return { directory: trimPickerPath(base), path: "" }
     const home = args.home()
     if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
     if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
-    const root = pickerRoot(raw)
-    if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
     return { directory: trimPickerPath(base), path: raw }
   }
 
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
     const key = trimPickerPath(directory)
     const existing = cache.get(key)
     if (existing) return existing
-    const request = args.sdk.client.file
-      .list({ directory: key, path: "" })
-      .then((result) => result.data ?? [])
+    const request = args.sdk.api.file
+      .list({ location: { directory: key } })
+      .then((result) => result.data)
       .catch(() => [])
       .then((nodes) =>
         nodes
           .filter((node) => node.type === "directory")
-          .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
+          .map((node) => {
+            const relative = trimPickerPath(normalizePickerDrive(node.path))
+            return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
+          }),
       )
     cache.set(key, request)
     return request
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
     const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
     const query = normalizePickerDrive(input.path)
     if (!pathInput) {
-      const results = await args.sdk.client.find
-        .files({ directory: input.directory, query, type: "directory", limit: 50 })
-        .then((result) => result.data ?? [])
+      const results = await args.sdk.api.file
+        .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
+        .then((result) => result.data.map((entry) => entry.path))
         .catch(() => [])
       if (!active()) return []
       return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)

+ 21 - 7
packages/app/src/components/edit-project.ts

@@ -1,6 +1,7 @@
 import { getFilename } from "@opencode-ai/core/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useMutation } from "@tanstack/solid-query"
+import { normalizeProjectInfo } from "@/context/global-sync/utils"
 import { createMemo } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useGlobal } from "@/context/global"
@@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
       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 },
-        })
+        if ((await serverCtx().sdk.protocol) !== "v1") return
+        const project = await serverCtx()
+          .sdk.client.project.update({
+            projectID: props.project.id,
+            directory: props.project.worktree,
+            name,
+            icon: { color: store.color || "", override: store.iconOverride || "" },
+            commands: { start },
+          })
+          .then((result) => result.data)
+        if (!project) return
+        // const project = await serverCtx().sdk.api.project.update({
+        //   projectID: props.project.id,
+        //   name,
+        //   icon: { color: store.color || "", override: store.iconOverride || "" },
+        //   commands: { start },
+        // })
+        serverCtx().sync.set("project", (items) =>
+          items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
+        )
         serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
         dialog.close()
         return

+ 80 - 88
packages/app/src/components/prompt-input-v2.tsx

@@ -37,11 +37,9 @@ 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 PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
 export type PromptInputV2ComposerController = PromptInputV2Interaction & {
   readonly model: PromptInputProps["controls"]["model"]
 }
@@ -51,9 +49,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
   const command = useCommand()
   const language = useLanguage()
 
-  useCommands(props)
-  useEditHandler(props)
-
   return (
     <div class="flex flex-col gap-3">
       <PromptInputV2
@@ -82,70 +77,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
   )
 }
 
-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()
@@ -310,7 +241,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
   )
   const resources = createMemo(() =>
     Object.values(sync().data.mcp_resource).map((resource) => ({
-      id: `resource:${resource.client}:${resource.uri}`,
+      id: `resource:${resource.server}:${resource.uri}`,
       kind: "resource" as const,
       label: `@${resource.name}`,
       path: resource.uri,
@@ -327,7 +258,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
         source: {
           type: "resource" as const,
           text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
-          clientName: resource.client,
+          clientName: resource.server,
           uri: resource.uri,
         },
       },
@@ -447,15 +378,16 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
     },
     view: {
       placeholder: designPlaceholder,
-      agent:
-        props.controls.agents.visible && props.controls.agents.options.length > 0
+      get agent() {
+        return 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,
+              onSelect: (value: string) => props.controls.agents.select(value),
               keybind: () => command.keybindParts("agent.cycle"),
             }
-          : undefined,
+          : undefined
+      },
       variant: {
         options: () => variants().map((value) => ({ id: value, label: value })),
         current: () => props.controls.model.selection.variant.current() ?? "default",
@@ -471,6 +403,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
     },
   })
   Object.defineProperty(controller, "model", { get: () => props.controls.model })
+
+  command.register("prompt-input", () => [
+    {
+      id: "file.attach",
+      title: language.t("prompt.action.attachFile"),
+      category: language.t("command.category.file"),
+      keybind: "mod+u",
+      disabled: controller.state.mode !== "normal",
+      onSelect: () => controller.attach(),
+    },
+    {
+      id: "prompt.mode.shell",
+      title: language.t("command.prompt.mode.shell"),
+      category: language.t("command.category.session"),
+      keybind: "mod+shift+x",
+      disabled: controller.state.mode === "shell",
+      onSelect: () => 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: controller.state.mode === "normal",
+      onSelect: () => controller.dispatch({ type: "mode.normal" }),
+    },
+  ])
+
+  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,
+          }),
+        )
+        controller.dispatch({ type: "mode.normal" })
+        controller.resetHistory()
+        prompt.set(edit.prompt, promptLength(edit.prompt))
+        controller.restoreFocus()
+        props.onEditLoaded?.()
+      },
+      { defer: true },
+    ),
+  )
+
   return controller as PromptInputV2ComposerController
 }
 
@@ -520,6 +508,7 @@ function PromptInputV2ModelControl(props: {
           fallback={
             <ButtonV2
               data-action="prompt-model"
+              data-control-type="dialog"
               variant="ghost-muted"
               size="normal"
               class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
@@ -533,19 +522,22 @@ function PromptInputV2ModelControl(props: {
         >
           <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",
-            }}
+            trigger={(triggerProps) => (
+              <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"
+                data-control-type="popover"
+              >
+                {content()}
+              </ButtonV2>
+            )}
             onClose={props.onClose}
-          >
-            {content()}
-          </ModelSelectorPopoverV2>
+          />
         </Show>
       </TooltipV2>
     </Show>

+ 107 - 2
packages/app/src/components/prompt-input.stories.tsx

@@ -1,6 +1,8 @@
 // @ts-nocheck
 import { createStore } from "solid-js/store"
+import type { Todo } from "@opencode-ai/sdk/v2"
 import { createPromptState } from "@/context/prompt"
+import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
 import { createPromptInputHistory, PromptInput } from "./prompt-input"
 
 function createPromptInputStoryRuntime() {
@@ -28,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,
@@ -65,7 +75,6 @@ function PromptInputExample() {
         open: () => setControls("reviewOpen", true),
       },
     },
-    newLayoutDesigns: true,
   }
   const addReviewComment = () => {
     const comment = controls.comments + 1
@@ -102,6 +111,93 @@ function PromptInputExample() {
   )
 }
 
+const todos: Todo[] = [
+  { id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
+  { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
+  { id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
+]
+
+function PromptInputWithOpenDock() {
+  const input = createPromptInputStoryRuntime()
+  const [controls, setControls] = createStore({
+    agent: "build",
+    activeTab: undefined as string | undefined,
+    todoCollapsed: false,
+  })
+  const inputControls = {
+    agents: {
+      available: [],
+      options: ["build"],
+      get current() {
+        return controls.agent
+      },
+      loading: false,
+      visible: true,
+      select: (agent?: string) => setControls("agent", agent ?? "build"),
+    },
+    model: {
+      selection: {
+        current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+        variant: { list: () => [], current: () => undefined, set: () => {} },
+      },
+      paid: true,
+      loading: false,
+    },
+    session: {
+      id: "story-session",
+      tabs: {
+        active: () => controls.activeTab,
+        all: () => [],
+        open: () => {},
+        setActive: (tab: string) => setControls("activeTab", tab),
+      },
+      reviewPanel: { opened: () => false, open: () => {} },
+    },
+  }
+  const state = {
+    blocked: () => false,
+    questionRequest: () => undefined,
+    permissionRequest: () => undefined,
+    permissionResponding: () => false,
+    decide: () => {},
+    todos: () => todos,
+    dock: () => true,
+    closing: () => false,
+    opening: () => false,
+  }
+  return (
+    <SessionComposerRegion
+      controller={createSessionComposerRegionController({
+        state,
+        sessionKey: () => "story-session",
+        sessionID: () => "story-session",
+        prompt: input.state,
+        ready: () => true,
+        centered: () => false,
+        todo: {
+          collapsed: () => controls.todoCollapsed,
+          onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
+        },
+        followup: () => undefined,
+        revert: () => undefined,
+        onResponseSubmit: () => {},
+        openParent: () => {},
+        setPromptRef: () => {},
+        setDockRef: () => {},
+      })}
+      promptInput={
+        <PromptInput
+          controls={inputControls}
+          {...input}
+          ref={() => {}}
+          newSessionWorktree=""
+          onNewSessionWorktreeReset={() => {}}
+        />
+      }
+    />
+  )
+}
+
 export default {
   title: "App/PromptInput",
   id: "app-prompt-input",
@@ -116,3 +212,12 @@ export const Basic = {
     </div>
   ),
 }
+
+export const DockAlreadyOpen = {
+  render: () => (
+    <div class="pt-10">
+      <h1 class="mb-4">Prompt Input with open Todo dock</h1>
+      <PromptInputWithOpenDock />
+    </div>
+  ),
+}

+ 26 - 24
packages/app/src/components/prompt-input.tsx

@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         type: "resource",
         name: resource.name,
         uri: resource.uri,
-        client: resource.client,
+        client: resource.server,
         display: resource.name,
         description: resource.description,
         mime: resource.mimeType,
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       title: cmd.name,
       description: cmd.description,
       type: "custom" as const,
-      source: cmd.source,
+      // source: cmd.source,
     }))
 
     return [...custom, ...builtin]
@@ -1723,29 +1723,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         >
                           <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",
-                            }}
+                            trigger={(triggerProps) => (
+                              <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"
+                              >
+                                <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>
+                            )}
                             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>

+ 124 - 19
packages/app/src/components/prompt-input/submit.test.ts

@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
 
 const createdClients: string[] = []
 const createdSessions: string[] = []
+const sessionCreateInputs: Array<{
+  agent?: string
+  model?: { id: string; providerID: string; variant?: string }
+  location?: { directory: string }
+}> = []
 const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
 const optimistic: Array<{
   directory?: string
@@ -20,9 +25,14 @@ const optimistic: Array<{
 const optimisticSeeded: boolean[] = []
 const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
 const promoted: Array<{ directory: string; sessionID: string }> = []
-const sentShell: string[] = []
+const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
 const syncedDirectories: string[] = []
 const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
+const sentPrompts: string[] = []
+const promptInputs: unknown[] = []
+const sentCommands: unknown[] = []
+const commands: Array<{ name: string }> = []
+let serverSessionSyncs = 0
 
 let params: { id?: string } = {}
 let search: { draftId?: string } = {}
@@ -31,7 +41,7 @@ let variant: string | undefined
 let permissionServer = "server-a"
 let createSessionGate: Promise<void> | undefined
 
-const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
+let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 const [promptStore, setPromptStore] = createStore<PromptStore>({
   prompt: promptValue,
   cursor: 0,
@@ -63,23 +73,39 @@ const prompt = {
 const clientFor = (directory: string) => {
   createdClients.push(directory)
   return {
-    session: {
-      create: async () => {
-        await createSessionGate
-        createdSessions.push(directory)
-        return {
-          data: {
+    api: {
+      session: {
+        create: async (input: (typeof sessionCreateInputs)[number]) => {
+          await createSessionGate
+          const location = input.location?.directory ?? directory
+          createdSessions.push(location)
+          sessionCreateInputs.push(input)
+          return {
             id: `session-${createdSessions.length}`,
+            projectID: "project",
+            agent: input.agent,
+            model: input.model,
+            cost: 0,
+            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+            time: { created: 1, updated: 1 },
             title: `New session ${createdSessions.length}`,
-          },
-        }
-      },
-      shell: async () => {
-        sentShell.push(directory)
-        return { data: undefined }
+            location: { directory: location },
+          }
+        },
+        prompt: async (input: unknown) => {
+          sentPrompts.push(directory)
+          promptInputs.push(input)
+          return { data: undefined }
+        },
+        command: async (input: unknown) => {
+          sentCommands.push(input)
+        },
+        shell: async (input: { sessionID: string; id?: string; command: string }) => {
+          sentShell.push(input)
+        },
       },
-      prompt: async () => ({ data: undefined }),
-      promptAsync: async () => ({ data: undefined }),
+    },
+    session: {
       command: async () => ({ data: undefined }),
       abort: async () => ({ data: undefined }),
     },
@@ -172,6 +198,7 @@ beforeAll(async () => {
         scope: "local",
         directory: "/repo/main",
         client: rootClient,
+        api: rootClient.api,
         url: "http://localhost:4096",
         createClient(opts: any) {
           return clientFor(opts.directory)
@@ -183,7 +210,7 @@ beforeAll(async () => {
 
   mock.module("@/context/sync", () => ({
     useSync: () => () => ({
-      data: { command: [] },
+      data: { command: commands },
       session: {
         optimistic: {
           add: (value: {
@@ -210,6 +237,9 @@ beforeAll(async () => {
       session: {
         remember: () => undefined,
         set: () => undefined,
+        sync: async () => {
+          serverSessionSyncs++
+        },
       },
       child: (directory: string) => {
         syncedDirectories.push(directory)
@@ -251,11 +281,17 @@ beforeAll(async () => {
 beforeEach(() => {
   createdClients.length = 0
   createdSessions.length = 0
+  sessionCreateInputs.length = 0
   enabledAutoAccept.length = 0
   optimistic.length = 0
   optimisticSeeded.length = 0
   promoted.length = 0
   promotedDrafts.length = 0
+  sentPrompts.length = 0
+  promptInputs.length = 0
+  sentCommands.length = 0
+  commands.length = 0
+  promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
   params = {}
   search = {}
   sentShell.length = 0
@@ -264,6 +300,7 @@ beforeEach(() => {
   variant = undefined
   permissionServer = "server-a"
   createSessionGate = undefined
+  serverSessionSyncs = 0
   for (const key of Object.keys(storedSessions)) delete storedSessions[key]
 })
 
@@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => {
 
     expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
-    expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+    expect(sessionCreateInputs).toEqual([
+      {
+        agent: "agent",
+        model: { id: "model", providerID: "provider", variant: undefined },
+        location: { directory: "/repo/worktree-a" },
+      },
+      {
+        agent: "agent",
+        model: { id: "model", providerID: "provider", variant: undefined },
+        location: { directory: "/repo/worktree-b" },
+      },
+    ])
+    expect(sentShell).toEqual([
+      expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
+      expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
+    ])
     expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
+    expect(serverSessionSyncs).toBe(0)
     expect(promoted).toEqual([
       { directory: "/repo/worktree-a", sessionID: "session-1" },
       { directory: "/repo/worktree-b", sessionID: "session-2" },
@@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
     const event = { preventDefault: () => undefined } as unknown as Event
 
     await submit.handleSubmit(event)
+    await Bun.sleep(0)
 
     expect(optimistic).toHaveLength(1)
     expect(optimistic[0]).toMatchObject({
@@ -427,6 +481,56 @@ describe("prompt submit worktree selection", () => {
         model: { providerID: "provider", modelID: "model", variant: "high" },
       },
     })
+    expect(sentPrompts).toEqual(["/repo/main"])
+    expect(promptInputs[0]).toMatchObject({
+      sessionID: "session-1",
+      text: "ls",
+      files: [],
+      agents: [],
+    })
+    expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
+    expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
+      { id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
+    ])
+  })
+
+  test("submits slash commands through the current session API", async () => {
+    params = { id: "session-1" }
+    variant = "high"
+    commands.push({ name: "review" })
+    promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
+
+    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,
+    })
+
+    await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+
+    expect(sentCommands).toEqual([
+      {
+        sessionID: "session-1",
+        id: expect.stringMatching(/^msg_/),
+        command: "review",
+        arguments: "staged changes",
+        agent: "agent",
+        model: { id: "model", providerID: "provider", variant: "high" },
+        files: [],
+      },
+    ])
+    expect(serverSessionSyncs).toBe(0)
   })
 
   test("uses an injected model selection", async () => {
@@ -487,7 +591,8 @@ describe("prompt submit worktree selection", () => {
 
     await submit.handleSubmit(event)
 
-    expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
+    expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
+    expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
     expect(optimisticSeeded).toEqual([true])
   })
 })

+ 68 - 33
packages/app/src/components/prompt-input/submit.ts

@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
 import { formatServerError } from "@/utils/server-errors"
 import { ScopedKey } from "@/utils/server-scope"
 import { createPromptSubmissionState } from "./submission-state"
+import { normalizeSessionInfo } from "@/utils/session"
+import { Event } from "@opencode-ai/schema/event"
 
 type PendingPrompt = {
   abort: AbortController
@@ -39,7 +41,7 @@ export type FollowupDraft = {
 }
 
 type FollowupSendInput = {
-  client: DirectorySDK["client"]
+  api: DirectorySDK["api"]["session"]
   serverSync: ServerSync
   sync: DirectorySync
   draft: FollowupDraft
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
         return false
       }
 
-      await input.client.session.command({
+      const messageID = Identifier.ascending("message")
+      await input.api.command({
         sessionID: input.draft.sessionID,
+        id: messageID,
         command: cmd,
         arguments: tail.join(" "),
         agent: input.draft.agent,
-        model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
-        variant: input.draft.variant,
-        parts: images.map((attachment) => ({
-          id: Identifier.ascending("part"),
-          type: "file" as const,
-          mime: attachment.mime,
-          url: attachment.dataUrl,
-          filename: attachment.filename,
+        model: {
+          id: input.draft.model.modelID,
+          providerID: input.draft.model.providerID,
+          variant: input.draft.variant,
+        },
+        files: images.map((attachment) => ({
+          uri: attachment.dataUrl,
+          name: attachment.filename,
         })),
       })
       return true
@@ -152,13 +156,37 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
       return false
     }
 
-    await input.client.session.promptAsync({
+    await input.api.prompt({
       sessionID: input.draft.sessionID,
+      id: messageID,
       agent: input.draft.agent,
       model: input.draft.model,
-      messageID,
-      parts: requestParts,
       variant: input.draft.variant,
+      legacyParts: requestParts,
+      text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
+      files: requestParts.flatMap((part) => {
+        if (part.type !== "file") return []
+        const text = part.source?.text
+        return [
+          {
+            uri: part.url,
+            name: part.filename,
+            mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
+          },
+        ]
+      }),
+      agents: requestParts.flatMap((part) =>
+        part.type === "agent"
+          ? [
+              {
+                name: part.name,
+                mention: part.source
+                  ? { start: part.source.start, end: part.source.end, text: part.source.value }
+                  : undefined,
+              },
+            ]
+          : [],
+      ),
     })
     return true
   } catch (err) {
@@ -210,6 +238,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
 
   const errorMessage = (err: unknown) => {
+    if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
     if (err && typeof err === "object" && "data" in err) {
       const data = (err as { data?: { message?: string } }).data
       if (data?.message) return data.message
@@ -222,6 +251,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     const sessionID = params.id
     if (!sessionID) return Promise.resolve()
 
+    serverSync().session.set("todo", sessionID, [])
+
     input.onAbort?.()
 
     const key = pendingKey(sessionID)
@@ -233,9 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return Promise.resolve()
     }
     return sdk()
-      .client.session.abort({
-        sessionID,
-      })
+      .api.session.interrupt({ sessionID })
       .catch(() => {})
   }
 
@@ -362,9 +391,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     let session = input.info()
     if (!session && isNewSession) {
-      const created = await client.session
-        .create()
-        .then((x) => x.data ?? undefined)
+      const created = await sdk()
+        .api.session.create({
+          agent: currentAgent.name,
+          model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
+          location: { directory: sessionDirectory },
+        })
+        .then(normalizeSessionInfo)
         .catch((err) => {
           showToast({
             title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -448,12 +481,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     if (mode === "shell") {
       clearInput()
-      client.session
-        .shell({
+      const eventID = Event.ID.create()
+      sdk()
+        .api.session.shell({
           sessionID: session.id,
+          id: eventID,
+          command: text,
           agent,
           model,
-          command: text,
         })
         .catch((err) => {
           showToast({
@@ -471,23 +506,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       const customCommand = sync().data.command.find((c) => c.name === commandName)
       if (customCommand) {
         clearInput()
-        client.session
-          .command({
+        const messageID = Identifier.ascending("message")
+        serverSync().session.set("session_status", session.id, { type: "busy" })
+        sdk()
+          .api.session.command({
             sessionID: session.id,
+            id: messageID,
             command: commandName,
             arguments: args.join(" "),
             agent,
-            model: `${model.providerID}/${model.modelID}`,
-            variant,
-            parts: images.map((attachment) => ({
-              id: Identifier.ascending("part"),
-              type: "file" as const,
-              mime: attachment.mime,
-              url: attachment.dataUrl,
-              filename: attachment.filename,
+            model: { id: model.modelID, providerID: model.providerID, variant },
+            files: images.map((attachment) => ({
+              uri: attachment.dataUrl,
+              name: attachment.filename,
             })),
           })
           .catch((err) => {
+            serverSync().session.set("session_status", session.id, { type: "idle" })
             showToast({
               title: language.t("prompt.toast.commandSendFailed.title"),
               description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -571,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
 
     void sendFollowupDraft({
-      client,
+      api: sdk().api.session,
       sync: sync(),
       serverSync: serverSync(),
       draft,

+ 51 - 55
packages/app/src/components/prompt-project-selector.tsx

@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
 import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
 import { pathKey } from "@/utils/path-key"
 import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
+import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
 
 export type PromptProject = {
   name?: string
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
 }) {
   const [triggerReady, setTriggerReady] = createSignal(false)
   let contentRef: HTMLDivElement | undefined
+  const dismiss = createMenuDismissController(() => contentRef)
   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) => {
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
     props.controller.active()
       ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
       : undefined
-  const afterClose = (callback: () => void) => {
-    const complete = () => {
-      if (contentRef?.isConnected) {
-        requestAnimationFrame(complete)
-        return
-      }
-      requestAnimationFrame(() => requestAnimationFrame(callback))
-    }
-    requestAnimationFrame(complete)
-  }
   const selectProject = (project: PromptProject) => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     props.controller.setOpen(false)
-    afterClose(() => props.controller.select(project))
+    dismiss.afterClose(() => props.controller.select(project))
   }
   const selectAction = (server?: string) => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     props.controller.setOpen(false)
-    afterClose(() => props.controller.add(server))
+    dismiss.afterClose(() => props.controller.add(server))
   }
   const selectActive = () => {
     const project = props.controller.activeProject()
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
     )
       .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
       .findLast((element) => element.offsetParent !== null)
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     target?.focus()
     queueMicrotask(() => {
       if (props.controller.open()) props.controller.setOpen(false)
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
       placement={props.placement ?? "bottom"}
       gutter={4}
       modal={false}
-      onOpenChange={(open) => props.controller.setOpen(open)}
+      onOpenChange={(open) => {
+        if (open) dismiss.allowTriggerRestore()
+        props.controller.setOpen(open)
+      }}
     >
       <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
       <DropdownMenu.Portal>
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
           id="prompt-project-menu"
           class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
           onOpenAutoFocus={(event) => event.preventDefault()}
-          onPointerDownOutside={() => (restoreTrigger = false)}
-          onFocusOutside={() => (restoreTrigger = false)}
-          onCloseAutoFocus={(event) => {
-            if (!restoreTrigger) event.preventDefault()
-          }}
+          onPointerDownOutside={dismiss.preventTriggerRestore}
+          onFocusOutside={dismiss.preventTriggerRestore}
+          onCloseAutoFocus={dismiss.onCloseAutoFocus}
         >
           <div class="flex flex-col p-0.5">
             <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -364,41 +356,45 @@ export function PromptProjectSelector(props: {
                 </button>
               </Show>
             </div>
-            <Show
-              when={props.controller.servers().length > 1}
-              fallback={
-                <DropdownMenu.RadioGroup value={selectedValue()}>
-                  <For each={props.controller.projects()}>
-                    {(project) => (
-                      <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
-                    )}
-                  </For>
-                </DropdownMenu.RadioGroup>
-              }
-            >
-              <For
-                each={props.controller
-                  .servers()
-                  .filter((server) =>
-                    props.controller.projects().some((project) => project.server?.key === server!.key),
-                  )}
+            <div class="max-h-[224px] overflow-y-auto">
+              <Show
+                when={props.controller.servers().length > 1}
+                fallback={
+                  <DropdownMenu.RadioGroup value={selectedValue()}>
+                    <For each={props.controller.projects()}>
+                      {(project) => (
+                        <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
+                      )}
+                    </For>
+                  </DropdownMenu.RadioGroup>
+                }
               >
-                {(server) => (
-                  <div>
-                    <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
-                      {server!.name}
+                <For
+                  each={props.controller
+                    .servers()
+                    .filter((server) =>
+                      props.controller.projects().some((project) => project.server?.key === server!.key),
+                    )}
+                >
+                  {(server) => (
+                    <div>
+                      <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
+                        {server!.name}
+                      </div>
+                      <DropdownMenu.RadioGroup value={selectedValue()}>
+                        <For
+                          each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
+                        >
+                          {(project) => (
+                            <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
+                          )}
+                        </For>
+                      </DropdownMenu.RadioGroup>
                     </div>
-                    <DropdownMenu.RadioGroup value={selectedValue()}>
-                      <For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
-                        {(project) => (
-                          <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
-                        )}
-                      </For>
-                    </DropdownMenu.RadioGroup>
-                  </div>
-                )}
-              </For>
-            </Show>
+                  )}
+                </For>
+              </Show>
+            </div>
           </div>
           <div class="h-px bg-v2-border-border-muted" />
           <div class="flex flex-col p-0.5">

+ 54 - 17
packages/app/src/components/server/server-row-menu.tsx

@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
 }> = (props) => {
   const language = useLanguage()
   const key = ServerConnection.key(props.server)
-  const builtin = ServerConnection.builtin(props.server)
-  const isDefault = () => props.controller.defaultKey() === key
+  return (
+    <ServerRowMenuView
+      server={props.server}
+      labels={serverMenuLabels(language)}
+      canDefault={props.controller.canDefault()}
+      isDefault={props.controller.defaultKey() === key}
+      onEdit={props.onEdit}
+      onSetDefault={() => props.controller.setDefault(key)}
+      onRemoveDefault={() => props.controller.setDefault(null)}
+      onRemove={() => props.controller.handleRemove(key)}
+      open={props.open}
+      onOpenChange={props.onOpenChange}
+    />
+  )
+}
+
+export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
+  return {
+    more: language.t("common.moreOptions"),
+    server: language.t("settings.section.server"),
+    edit: language.t("dialog.server.menu.edit"),
+    default: language.t("dialog.server.menu.default"),
+    defaultRemove: language.t("dialog.server.menu.defaultRemove"),
+    delete: language.t("dialog.server.menu.delete"),
+  }
+}
 
+export const ServerRowMenuView: Component<{
+  server: ServerConnection.Any
+  labels: ReturnType<typeof serverMenuLabels>
+  canDefault: boolean
+  isDefault: boolean
+  onEdit: (server: ServerConnection.Http) => void
+  onSetDefault: () => void
+  onRemoveDefault: () => void
+  onRemove: () => void
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
+}> = (props) => {
+  const builtin = () => ServerConnection.builtin(props.server)
+  const httpServer = () => (props.server.type === "http" ? props.server : undefined)
   return (
     <MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
       <MenuV2.Trigger
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
         variant="ghost-muted"
         size="small"
         icon={<IconV2 name="outline-dots" />}
-        aria-label={language.t("common.moreOptions")}
+        aria-label={props.labels.more}
       />
       <MenuV2.Portal>
         <MenuV2.Content>
           <MenuV2.Group>
-            <MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
+            <MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
             <MenuV2.Item
-              disabled={builtin || props.server.type !== "http"}
-              onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
+              disabled={builtin() || !httpServer()}
+              onSelect={() => {
+                const server = httpServer()
+                if (server) props.onEdit(server)
+              }}
             >
-              {language.t("dialog.server.menu.edit")}
+              {props.labels.edit}
             </MenuV2.Item>
-            <Show when={props.controller.canDefault() && !isDefault()}>
-              <MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
-                {language.t("dialog.server.menu.default")}
-              </MenuV2.Item>
+            <Show when={props.canDefault && !props.isDefault}>
+              <MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
             </Show>
-            <Show when={props.controller.canDefault() && isDefault()}>
-              <MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
-                {language.t("dialog.server.menu.defaultRemove")}
-              </MenuV2.Item>
+            <Show when={props.canDefault && props.isDefault}>
+              <MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
             </Show>
             <MenuV2.Separator />
-            <MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
-              {language.t("dialog.server.menu.delete")}
+            <MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
+              {props.labels.delete}
             </MenuV2.Item>
           </MenuV2.Group>
         </MenuV2.Content>

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

@@ -4,4 +4,3 @@ 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"

+ 0 - 16
packages/app/src/components/session/session-new-design-view.tsx

@@ -1,16 +0,0 @@
-import type { JSX } from "solid-js"
-import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
-import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
-
-export function NewSessionDesignView(props: { children: JSX.Element }) {
-  return (
-    <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
-      <div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
-        <div class={NEW_SESSION_CONTENT_WIDTH}>
-          <WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
-          <div class="mt-8">{props.children}</div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 8 - 5
packages/app/src/components/settings-general.tsx

@@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
   const serverSdk = useServerSDK()
 
   const [shells] = createResource(
-    () =>
-      serverSdk()
-        .client.pty.shells()
-        .then((res) => res.data ?? [])
-        .catch(() => [] as ShellOption[]),
+    async () => {
+      const sdk = serverSdk()
+      if ((await sdk.protocol) === "v1") {
+        return (await sdk.client.pty.shells()).data ?? []
+      }
+      // return (await sdk.api.pty.shells()).data
+      return [] as ShellOption[]
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 289 - 61
packages/app/src/components/settings-keybinds.tsx

@@ -30,6 +30,8 @@ type KeybindMeta = {
 
 type KeybindMap = Record<string, string | undefined>
 type CommandContext = ReturnType<typeof useCommand>
+type LanguageContext = ReturnType<typeof useLanguage>
+type SettingsContext = ReturnType<typeof useSettings>
 
 const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
 
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
   return value as KeybindMap
 }
 
-function listFor(command: CommandContext, map: KeybindMap, palette: string) {
+function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
   const out = new Map<string, KeybindMeta>()
   out.set(PALETTE_ID, { title: palette, group: "General" })
 
@@ -262,7 +264,274 @@ function useKeyCapture(input: {
   })
 }
 
+export function createKeybindSettingsController(
+  input: {
+    command: Pick<CommandContext, "catalog" | "options" | "keybinds">
+    settings: {
+      current: { keybinds: unknown }
+      keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
+    }
+    target?: Document
+    notify?: (toast: { title: string; description: string }) => void
+  },
+  language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
+) {
+  const [store, setStore] = createStore({ active: null as string | null })
+  const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
+  const list = createMemo(() => {
+    language.locale()
+    return listFor(input.command, overrides(), language.t("command.palette"))
+  })
+  const grouped = createMemo(() => groupedFor(list()))
+  const title = (id: string) => list().get(id)?.title ?? ""
+  const effective = (id: string) => {
+    if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
+
+    const custom = input.settings.keybinds.get(id)
+    if (typeof custom === "string") return custom
+
+    const live = input.command.options.find((item) => item.id === id)
+    if (live?.keybind) return live.keybind
+    return input.command.catalog.find((item) => item.id === id)?.keybind
+  }
+  const used = createMemo(() => {
+    const value = new Map<string, { id: string; title: string }[]>()
+
+    for (const id of list().keys()) {
+      for (const signature of signatures(effective(id))) {
+        const items = value.get(signature)
+        if (items) {
+          items.push({ id, title: title(id) })
+          continue
+        }
+        value.set(signature, [{ id, title: title(id) }])
+      }
+    }
+
+    return value
+  })
+  const stop = () => {
+    if (!store.active) return
+    setStore("active", null)
+    input.command.keybinds(true)
+  }
+  const toggle = (id: string) => {
+    if (store.active === id) {
+      stop()
+      return
+    }
+    if (store.active) stop()
+    setStore("active", id)
+    input.command.keybinds(false)
+  }
+  const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
+
+  const handle = (event: KeyboardEvent) => {
+    const id = store.active
+    if (!id) return
+
+    event.preventDefault()
+    event.stopPropagation()
+    event.stopImmediatePropagation()
+
+    if (event.key === "Escape") {
+      stop()
+      return
+    }
+
+    const clear =
+      (event.key === "Backspace" || event.key === "Delete") &&
+      !event.ctrlKey &&
+      !event.metaKey &&
+      !event.altKey &&
+      !event.shiftKey
+    if (clear) {
+      input.settings.keybinds.set(id, "none")
+      stop()
+      return
+    }
+
+    const next = recordKeybind(event)
+    if (!next) return
+
+    const conflicts = new Map<string, string>()
+    for (const signature of signatures(next)) {
+      for (const item of used().get(signature) ?? []) {
+        if (item.id === id) continue
+        conflicts.set(item.id, item.title)
+      }
+    }
+
+    if (conflicts.size > 0) {
+      notify({
+        title: language.t("settings.shortcuts.conflict.title"),
+        description: language.t("settings.shortcuts.conflict.description", {
+          keybind: formatKeybind(next, language.t),
+          titles: [...conflicts.values()].join(", "),
+        }),
+      })
+      return
+    }
+
+    input.settings.keybinds.set(id, next)
+    stop()
+  }
+
+  const target = input.target ?? (typeof document === "object" ? document : undefined)
+  if (target) makeEventListener(target, "keydown", handle, { capture: true })
+
+  onCleanup(() => {
+    if (store.active) input.command.keybinds(true)
+  })
+
+  return {
+    catalog: {
+      groups: GROUPS,
+      filtered: (query: string) =>
+        filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
+      title,
+      keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
+    },
+    capture: {
+      active: () => store.active,
+      toggle,
+    },
+    settings: {
+      hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
+      reset: () => {
+        stop()
+        input.settings.keybinds.resetAll()
+        notify({
+          title: language.t("settings.shortcuts.reset.toast.title"),
+          description: language.t("settings.shortcuts.reset.toast.description"),
+        })
+      },
+    },
+  }
+}
+
+function SettingsKeybindsV2() {
+  const command = useCommand()
+  const settings = useSettings()
+  const controller = createKeybindSettingsController({
+    command,
+    settings,
+  })
+
+  return (
+    <SettingsKeybindsV2View
+      groups={controller.catalog.groups}
+      filtered={controller.catalog.filtered}
+      title={controller.catalog.title}
+      keybind={controller.catalog.keybind}
+      active={controller.capture.active}
+      onCapture={controller.capture.toggle}
+      hasOverrides={controller.settings.hasOverrides}
+      onReset={controller.settings.reset}
+    />
+  )
+}
+
+function SettingsKeybindsV2View(props: {
+  groups: KeybindGroup[]
+  filtered: (query: string) => Map<KeybindGroup, string[]>
+  title: (id: string) => string
+  keybind: (id: string) => string
+  active: () => string | null
+  onCapture: (id: string) => void
+  hasOverrides: () => boolean
+  onReset: () => void
+}) {
+  const language = useLanguage()
+  const [store, setStore] = createStore({ filter: "" })
+  const filtered = createMemo(() => props.filtered(store.filter))
+  const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
+
+  return (
+    <>
+      <div class="settings-v2-tab-header settings-v2-tab-header--stacked">
+        <div class="settings-v2-tab-header-row">
+          <h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
+          <ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
+            {language.t("settings.shortcuts.reset.button")}
+          </ButtonV2>
+        </div>
+        <div class="settings-v2-tab-search">
+          <TextInputV2
+            type="search"
+            appearance="base"
+            value={store.filter}
+            onInput={(event) => setStore("filter", event.currentTarget.value)}
+            placeholder={language.t("settings.shortcuts.search.placeholder")}
+            spellcheck={false}
+            autocorrect="off"
+            autocomplete="off"
+            autocapitalize="off"
+            aria-label={language.t("settings.shortcuts.search.placeholder")}
+          />
+          <Show when={store.filter}>
+            <IconButtonV2
+              type="button"
+              variant="ghost-muted"
+              size="small"
+              class="settings-v2-tab-search-clear"
+              icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
+              onClick={() => setStore("filter", "")}
+            />
+          </Show>
+        </div>
+      </div>
+      <div class="settings-v2-tab-body">
+        <div class="settings-v2-shortcuts flex flex-col gap-8">
+          <For each={props.groups}>
+            {(group) => (
+              <Show when={(filtered().get(group) ?? []).length > 0}>
+                <div class="settings-v2-section">
+                  <h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
+                  <SettingsListV2>
+                    <For each={filtered().get(group) ?? []}>
+                      {(id) => (
+                        <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
+                          <span>{props.title(id)}</span>
+                          <button
+                            type="button"
+                            data-keybind-id={id}
+                            classList={{
+                              "settings-v2-keybind-button": true,
+                              "settings-v2-keybind-button--active": props.active() === id,
+                            }}
+                            onClick={() => props.onCapture(id)}
+                          >
+                            <Show
+                              when={props.active() === id}
+                              fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
+                            >
+                              {language.t("settings.shortcuts.pressKeys")}
+                            </Show>
+                          </button>
+                        </div>
+                      )}
+                    </For>
+                  </SettingsListV2>
+                </div>
+              </Show>
+            )}
+          </For>
+          <Show when={store.filter && !hasResults()}>
+            <div class="settings-v2-shortcuts-status">
+              <span>{language.t("settings.shortcuts.search.empty")}</span>
+              <span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
+            </div>
+          </Show>
+        </div>
+      </div>
+    </>
+  )
+}
+
 export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
+  if (props.v2) return <SettingsKeybindsV2 />
+
   const command = useCommand()
   const language = useLanguage()
   const settings = useSettings()
@@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
   )
 
   return (
-    <Show
-      when={props.v2}
-      fallback={
-        <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
-          <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
-            <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
-              <div class="flex items-center justify-between gap-4">
-                <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
-                <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
-                  {language.t("settings.shortcuts.reset.button")}
-                </Button>
-              </div>
-
-              <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
-                <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
-                <TextField
-                  variant="ghost"
-                  type="text"
-                  value={store.filter}
-                  onChange={(v) => setStore("filter", v)}
-                  placeholder={language.t("settings.shortcuts.search.placeholder")}
-                  spellcheck={false}
-                  autocorrect="off"
-                  autocomplete="off"
-                  autocapitalize="off"
-                  class="flex-1"
-                />
-                <Show when={store.filter}>
-                  <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
-                </Show>
-              </div>
-            </div>
-          </div>
-          {groups}
-        </div>
-      }
-    >
-      <>
-        <div class="settings-v2-tab-header settings-v2-tab-header--stacked">
-          <div class="settings-v2-tab-header-row">
-            <h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
-            <ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
+    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+      <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
+        <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
+          <div class="flex items-center justify-between gap-4">
+            <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
+            <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
               {language.t("settings.shortcuts.reset.button")}
-            </ButtonV2>
+            </Button>
           </div>
-          <div class="settings-v2-tab-search">
-            <TextInputV2
-              type="search"
-              appearance="base"
+
+          <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
+            <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
+            <TextField
+              variant="ghost"
+              type="text"
               value={store.filter}
-              onInput={(event) => setStore("filter", event.currentTarget.value)}
+              onChange={(v) => setStore("filter", v)}
               placeholder={language.t("settings.shortcuts.search.placeholder")}
               spellcheck={false}
               autocorrect="off"
               autocomplete="off"
               autocapitalize="off"
-              aria-label={language.t("settings.shortcuts.search.placeholder")}
+              class="flex-1"
             />
             <Show when={store.filter}>
-              <IconButtonV2
-                type="button"
-                variant="ghost-muted"
-                size="small"
-                class="settings-v2-tab-search-clear"
-                icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
-                onClick={() => setStore("filter", "")}
-              />
+              <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
             </Show>
           </div>
         </div>
-        <div class="settings-v2-tab-body">{groups}</div>
-      </>
-    </Show>
+      </div>
+      {groups}
+    </div>
   )
 }

+ 31 - 26
packages/app/src/components/settings-providers.tsx

@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { createMemo, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
-import { useServerSDK } from "@/context/server-sdk"
+import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,8 +39,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
   const dialog = useDialog()
   const language = useLanguage()
   const serverSDK = useServerSDK()
+  const protocol = useServerProtocol()
   const serverSync = useServerSync()
-  const providers = useProviders()
+  const providers = useProviders(() => undefined)
   const providerConnect = useProviderConnectController({ onBack: props.onBack })
 
   const connect = (provider?: string) => {
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
     return language.t("settings.providers.tag.other")
   }
 
-  const canDisconnect = (item: ProviderItem) => source(item) !== "env"
+  const canDisconnect = (item: ProviderItem) =>
+    source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
 
   const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
 
@@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
   }
 
   const disableProvider = async (providerID: string, name: string) => {
+    if (protocol() !== "v1") return
     const before = serverSync().data.config.disabled_providers ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
     serverSync().set("config", "disabled_providers", next)
@@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
               )}
             </For>
 
-            <div
-              class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
-              data-component="custom-provider-section"
-            >
-              <div class="flex flex-col min-w-0">
-                <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
-                  <ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
-                  <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
-                  <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+            <Show when={protocol() === "v1"}>
+              <div
+                class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
+                data-component="custom-provider-section"
+              >
+                <div class="flex flex-col min-w-0">
+                  <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
+                    <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
+                    <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
+                    <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+                  </div>
+                  <span class="text-12-regular text-text-weak pl-8">
+                    {language.t("settings.providers.custom.description")}
+                  </span>
                 </div>
-                <span class="text-12-regular text-text-weak pl-8">
-                  {language.t("settings.providers.custom.description")}
-                </span>
+                <Button
+                  size="large"
+                  variant="secondary"
+                  icon="plus-small"
+                  onClick={() => {
+                    dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
+                  }}
+                >
+                  {language.t("common.connect")}
+                </Button>
               </div>
-              <Button
-                size="large"
-                variant="secondary"
-                icon="plus-small"
-                onClick={() => {
-                  dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
-                }}
-              >
-                {language.t("common.connect")}
-              </Button>
-            </div>
+            </Show>
           </SettingsList>
 
           <Button

+ 18 - 2
packages/app/src/components/settings-v2/dialog-settings-v2.tsx

@@ -1,4 +1,4 @@
-import { Component, createSignal, startTransition } from "solid-js"
+import { Component, createMemo, createSignal, startTransition } from "solid-js"
 import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
 import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
 import "./settings-v2.css"
 import { SettingsServersV2 } from "./servers"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useLayout } from "@/context/layout"
+import { useTabs } from "@/context/tabs"
+import { useServerSync } from "@/context/server-sync"
 
 export const DialogSettings: Component<{
   sessionID?: string
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
   const language = useLanguage()
   const platform = usePlatform()
   const dialog = useDialog()
+  const layout = useLayout()
+  const tabs = useTabs()
+  const serverSync = useServerSync()
   const [tab, setTab] = createSignal(props.defaultValue ?? "general")
+  const directory = createMemo(() => {
+    const route = layout.route()
+    if (route.type === "dir-new-sesssion") return route.dir
+    if (route.type === "draft") {
+      const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
+      return draft?.type === "draft" ? draft.directory : undefined
+    }
+    if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
+    return undefined
+  })
 
   const showProviders = () => {
     void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
           <SettingsServersV2 />
         </TabsV2.Content>
         <TabsV2.Content value="providers" class="settings-v2-panel">
-          <SettingsProvidersV2 onBack={showProviders} />
+          <SettingsProvidersV2 directory={directory} onBack={showProviders} />
         </TabsV2.Content>
         <TabsV2.Content value="models" class="settings-v2-panel">
           <SettingsModelsV2 />

+ 8 - 5
packages/app/src/components/settings-v2/general.tsx

@@ -122,11 +122,14 @@ export const SettingsGeneralV2: Component<{
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const [shells] = createResource(
-    () =>
-      serverSdk()
-        .client.pty.shells()
-        .then((res) => res.data ?? [])
-        .catch(() => [] as ShellOption[]),
+    async () => {
+      const sdk = serverSdk()
+      if ((await sdk.protocol) === "v1") {
+        return (await sdk.client.pty.shells()).data ?? []
+      }
+      // return (await sdk.api.pty.shells()).data
+      return [] as ShellOption[]
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 83 - 34
packages/app/src/components/settings-v2/models.tsx

@@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { type Component, For, Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { useModels } from "@/context/models"
+import { useServerSDK } from "@/context/server-sdk"
 import { popularProviders } from "@/hooks/use-providers"
+import { Persist, persisted } from "@/utils/persist"
 import { SettingsListV2 } from "./parts/list"
 import { SettingsRowV2 } from "./parts/row"
 import "./settings-v2.css"
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
 export const SettingsModelsV2: Component = () => {
   const language = useLanguage()
   const models = useModels()
+  const serverSdk = useServerSDK()
+  const [store, setStore] = persisted(
+    Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
+    createStore({ collapsed: {} as Record<string, boolean> }),
+  )
 
   const list = useFilteredList<ModelItem>({
     items: (_filter) => models.list(),
@@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
             }
           >
             <For each={list.grouped.latest}>
-              {(group) => (
-                <div class="settings-v2-section" data-component="settings-models-provider">
-                  <div class="settings-v2-models-group-header">
-                    <ProviderIcon
-                      id={group.category}
-                      width={PROVIDER_ICON_SIZE}
-                      height={PROVIDER_ICON_SIZE}
-                      class="settings-v2-models-provider-icon shrink-0"
-                    />
-                    <h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
+              {(group) => {
+                const searching = () => list.filter().length > 0
+                const expanded = () => searching() || !store.collapsed[group.category]
+
+                return (
+                  <div
+                    class="settings-v2-section"
+                    data-component="settings-models-provider"
+                    data-expanded={expanded() ? "" : undefined}
+                  >
+                    <h3 class="settings-v2-models-group-header">
+                      <button
+                        type="button"
+                        class="settings-v2-models-group-trigger"
+                        aria-expanded={expanded()}
+                        disabled={searching()}
+                        onClick={() => setStore("collapsed", group.category, expanded())}
+                      >
+                        <span class="settings-v2-models-group-chevron">
+                          <Show
+                            when={expanded()}
+                            fallback={
+                              <svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
+                                <path
+                                  d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
+                                  fill="currentColor"
+                                />
+                              </svg>
+                            }
+                          >
+                            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+                              <path
+                                d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
+                                fill="currentColor"
+                              />
+                            </svg>
+                          </Show>
+                        </span>
+                        <span class="settings-v2-models-group-label">
+                          <ProviderIcon
+                            id={group.category}
+                            width={PROVIDER_ICON_SIZE}
+                            height={PROVIDER_ICON_SIZE}
+                            class="settings-v2-models-provider-icon shrink-0"
+                          />
+                          <span class="settings-v2-section-title">{group.items[0].provider.name}</span>
+                        </span>
+                      </button>
+                    </h3>
+                    <Show when={expanded()}>
+                      <SettingsListV2>
+                        <For each={group.items}>
+                          {(item) => {
+                            const key = { providerID: item.provider.id, modelID: item.id }
+                            return (
+                              <SettingsRowV2 title={item.name} description="">
+                                <div>
+                                  <Switch
+                                    checked={models.visible(key)}
+                                    onChange={(checked) => {
+                                      models.setVisibility(key, checked)
+                                    }}
+                                    hideLabel
+                                  >
+                                    {item.name}
+                                  </Switch>
+                                </div>
+                              </SettingsRowV2>
+                            )
+                          }}
+                        </For>
+                      </SettingsListV2>
+                    </Show>
                   </div>
-                  <SettingsListV2>
-                    <For each={group.items}>
-                      {(item) => {
-                        const key = { providerID: item.provider.id, modelID: item.id }
-                        return (
-                          <SettingsRowV2 title={item.name} description="">
-                            <div>
-                              <Switch
-                                checked={models.visible(key)}
-                                onChange={(checked) => {
-                                  models.setVisibility(key, checked)
-                                }}
-                                hideLabel
-                              >
-                                {item.name}
-                              </Switch>
-                            </div>
-                          </SettingsRowV2>
-                        )
-                      }}
-                    </For>
-                  </SettingsListV2>
-                </div>
-              )}
+                )
+              }}
             </For>
           </Show>
         </Show>

+ 40 - 30
packages/app/src/components/settings-v2/providers.tsx

@@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { showToast } from "@/utils/toast"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { createMemo, type Component, For, Show } from "solid-js"
+import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
-import { useServerSDK } from "@/context/server-sdk"
+import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
 import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -29,17 +29,21 @@ const PROVIDER_NOTES = [
 
 const PROVIDER_ICON_SIZE = 16
 
-export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
+export const SettingsProvidersV2: Component<{
+  directory: Accessor<string | undefined>
+  onBack?: () => void
+}> = (props) => {
   const dialog = useDialog()
   const language = useLanguage()
   const serverSdk = useServerSDK()
+  const protocol = useServerProtocol()
   const serverSync = useServerSync()
-  const providers = useProviders()
+  const providers = useProviders(props.directory)
   const providerConnect = useProviderConnectController({ onBack: props.onBack })
 
   const connect = (provider?: string) => {
     providerConnect.select(provider)
-    void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
+    void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
   }
 
   const connected = createMemo(() => {
@@ -77,7 +81,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
     return language.t("settings.providers.tag.other")
   }
 
-  const canDisconnect = (item: ProviderItem) => source(item) !== "env"
+  const canDisconnect = (item: ProviderItem) =>
+    source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
 
   const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
 
@@ -90,6 +95,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
   }
 
   const disableProvider = async (providerID: string, name: string) => {
+    if (protocol() !== "v1") return
     const before = serverSync().data.config.disabled_providers ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
     serverSync().set("config", "disabled_providers", next)
@@ -218,33 +224,37 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
               )}
             </For>
 
-            <div class="settings-v2-provider-row" data-component="custom-provider-section">
-              <div class="settings-v2-provider-lead">
-                <ProviderIcon
-                  id="session.synthetic"
-                  width={PROVIDER_ICON_SIZE}
-                  height={PROVIDER_ICON_SIZE}
-                  class="settings-v2-provider-icon shrink-0"
-                />
-                <div class="settings-v2-provider-copy">
-                  <div class="settings-v2-provider-main">
-                    <span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
-                    <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+            <Show when={protocol() === "v1"}>
+              <div class="settings-v2-provider-row" data-component="custom-provider-section">
+                <div class="settings-v2-provider-lead">
+                  <ProviderIcon
+                    id="synthetic"
+                    width={PROVIDER_ICON_SIZE}
+                    height={PROVIDER_ICON_SIZE}
+                    class="settings-v2-provider-icon shrink-0"
+                  />
+                  <div class="settings-v2-provider-copy">
+                    <div class="settings-v2-provider-main">
+                      <span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
+                      <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+                    </div>
+                    <p class="settings-v2-provider-description">
+                      {language.t("settings.providers.custom.description")}
+                    </p>
                   </div>
-                  <p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
                 </div>
+                <ButtonV2
+                  size="normal"
+                  variant="neutral"
+                  icon="plus"
+                  onClick={() => {
+                    dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
+                  }}
+                >
+                  {language.t("common.connect")}
+                </ButtonV2>
               </div>
-              <ButtonV2
-                size="normal"
-                variant="neutral"
-                icon="plus"
-                onClick={() => {
-                  dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
-                }}
-              >
-                {language.t("common.connect")}
-              </ButtonV2>
-            </div>
+            </Show>
           </SettingsListV2>
 
           <button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>

+ 56 - 2
packages/app/src/components/settings-v2/settings-v2.css

@@ -373,14 +373,64 @@
 }
 
 .settings-v2-models {
-  gap: 24px;
+  gap: 12px;
+}
+
+.settings-v2-models .settings-v2-section {
+  gap: 12px;
+}
+
+.settings-v2-models .settings-v2-section[data-expanded] {
+  padding-bottom: 12px;
 }
 
 .settings-v2-models-group-header {
+  display: flex;
+  align-items: center;
+  height: 28px;
+}
+
+.settings-v2-models-group-trigger {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  height: 28px;
+  padding: 4px 8px 4px 4px;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  color: var(--v2-text-text-base);
+}
+
+.settings-v2-models-group-trigger:focus-visible {
+  outline: 2px solid var(--v2-border-border-focus);
+  outline-offset: 2px;
+}
+
+@media (hover: hover) {
+  .settings-v2-models-group-trigger:not(:disabled):hover {
+    background-color: var(--v2-background-bg-layer-02);
+  }
+}
+
+.settings-v2-models-group-trigger:disabled {
+  cursor: default;
+}
+
+.settings-v2-models-group-chevron {
+  display: flex;
+  width: 20px;
+  height: 20px;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  color: var(--v2-icon-icon-muted);
+}
+
+.settings-v2-models-group-label {
   display: flex;
   align-items: center;
   gap: 8px;
-  padding-bottom: 8px;
 }
 
 .settings-v2-models .settings-v2-section-title {
@@ -394,6 +444,10 @@
   color: var(--v2-icon-icon-base);
 }
 
+.settings-v2-models [data-component="settings-v2-list"] {
+  padding-inline: 16px;
+}
+
 .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
   margin-top: 0;
 }

+ 35 - 24
packages/app/src/components/status-popover-body.tsx

@@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
 import { useGlobal } from "@/context/global"
 import { useSettings } from "@/context/settings"
 import { useMcpToggle } from "@/context/mcp"
+import { useServerProtocol } from "@/context/server-sdk"
 
 const pluginEmptyMessage = (value: string, file: string): JSXElement => {
   const parts = value.split(file)
@@ -257,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
   const language = useLanguage()
   const navigate = useNavigate()
   const settings = useSettings()
+  const protocol = useServerProtocol()
 
   const fail = (err: unknown) => {
     showToast({
@@ -276,7 +278,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
     dialogDead = true
     dialogRun += 1
   })
-  const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
+  const sortedServers = createMemo(() => {
+    const list = settings.general.newLayoutDesigns()
+      ? global.servers.list()
+      : global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
+    return listServersByHealth(list, server.key, global.servers.health)
+  })
   const toggleMcp = useMcpToggle()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -303,7 +310,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
         <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
           {!settings.general.newLayoutDesigns() && (
             <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
-              {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
+              {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
               {language.t("status.popover.tab.servers")}
             </Tabs.Trigger>
           )}
@@ -315,10 +322,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
             {lspCount() > 0 ? `${lspCount()} ` : ""}
             {language.t("status.popover.tab.lsp")}
           </Tabs.Trigger>
-          <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
-            {pluginCount() > 0 ? `${pluginCount()} ` : ""}
-            {language.t("status.popover.tab.plugins")}
-          </Tabs.Trigger>
+          <Show when={protocol() === "v1"}>
+            <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
+              {pluginCount() > 0 ? `${pluginCount()} ` : ""}
+              {language.t("status.popover.tab.plugins")}
+            </Tabs.Trigger>
+          </Show>
         </Tabs.List>
 
         {!settings.general.newLayoutDesigns() && (
@@ -478,25 +487,27 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
           </div>
         </Tabs.Content>
 
-        <Tabs.Content value="plugins">
-          <div class="flex flex-col px-2 pb-2">
-            <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
-              <Show
-                when={plugins().length > 0}
-                fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
-              >
-                <For each={plugins()}>
-                  {(plugin) => (
-                    <div class="flex items-center gap-2 w-full px-2 py-1">
-                      <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
-                      <span class="text-14-regular text-text-base truncate">{plugin}</span>
-                    </div>
-                  )}
-                </For>
-              </Show>
+        <Show when={protocol() === "v1"}>
+          <Tabs.Content value="plugins">
+            <div class="flex flex-col px-2 pb-2">
+              <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+                <Show
+                  when={plugins().length > 0}
+                  fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
+                >
+                  <For each={plugins()}>
+                    {(plugin) => (
+                      <div class="flex items-center gap-2 w-full px-2 py-1">
+                        <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
+                        <span class="text-14-regular text-text-base truncate">{plugin}</span>
+                      </div>
+                    )}
+                  </For>
+                </Show>
+              </div>
             </div>
-          </div>
-        </Tabs.Content>
+          </Tabs.Content>
+        </Show>
       </Tabs>
     </div>
   )

+ 24 - 2
packages/app/src/components/status-popover-indicator.test.ts

@@ -1,11 +1,21 @@
 import { describe, expect, test } from "bun:test"
-import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
+import {
+  hasNonBlockingServiceIssue,
+  hasServiceNeedingAttention,
+  serverStatusDotClass,
+} from "./status-popover-indicator"
 
 describe("serverStatusDotClass", () => {
   test("uses the success token while the server and services are healthy", () => {
     expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
   })
 
+  test("uses the session attention token when a service needs attention", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
+      "bg-v2-background-bg-accent",
+    )
+  })
+
   test("uses the warning token for non-blocking issues while the server is online", () => {
     expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
   })
@@ -26,7 +36,7 @@ describe("hasNonBlockingServiceIssue", () => {
     expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
     expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
     expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
-    expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
+    expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
   })
 
   test("detects LSP failures that do not block chatting", () => {
@@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
     expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
   })
 })
+
+describe("hasServiceNeedingAttention", () => {
+  test("detects MCP states that need user attention", () => {
+    expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
+    expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
+  })
+
+  test("ignores states that do not need user attention", () => {
+    expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
+    expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
+  })
+})

+ 15 - 4
packages/app/src/components/status-popover-indicator.ts

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

+ 19 - 1
packages/app/src/components/status-popover.tsx

@@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server"
 import { useServerSDK } from "@/context/server-sdk"
 import { useSync } from "@/context/sync"
 import { useGlobal } from "@/context/global"
-import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
+import {
+  hasNonBlockingServiceIssue,
+  hasServiceNeedingAttention,
+  serverStatusDotClass,
+} from "./status-popover-indicator"
 
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -22,6 +26,11 @@ export function StatusPopover() {
   const [shown, setShown] = createSignal(false)
   const serverHealth = () => global.servers.health[server.key]?.healthy
   const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
+  const attention = createMemo(() =>
+    hasServiceNeedingAttention({
+      mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
+    }),
+  )
   const issue = createMemo(() =>
     hasNonBlockingServiceIssue({
       mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -49,6 +58,7 @@ export function StatusPopover() {
             class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
               ready: ready(),
               serverHealth: serverHealth(),
+              attention: attention(),
               issue: issue(),
             })}`}
           />
@@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
   const [shown, setShown] = createSignal(false)
   const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
   const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
+  const attention = createMemo(() =>
+    hasServiceNeedingAttention({
+      mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
+    }),
+  )
   const issue = createMemo(() =>
     hasNonBlockingServiceIssue({
       mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
     shown: shown(),
     ready: ready(),
     serverHealth: serverHealth(),
+    attention: attention(),
     issue: issue(),
     label: language.t("status.popover.trigger"),
     onOpenChange: setShown,
@@ -118,6 +134,7 @@ function ServerStatusPopover() {
     shown: shown(),
     ready: serverHealth() !== undefined,
     serverHealth: serverHealth(),
+    attention: false,
     issue: false,
     label: language.t("status.popover.trigger"),
     onOpenChange: setShown,
@@ -135,6 +152,7 @@ type StatusPopoverState = {
   shown: boolean
   ready: boolean
   serverHealth: boolean | undefined
+  attention: boolean
   issue: boolean
   label: string
   onOpenChange: (value: boolean) => void

+ 59 - 26
packages/app/src/components/terminal.tsx

@@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
   // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
   const connection = useServerSDK()().server
   const directory = sdk().directory
-  const client = sdk().client
   const url = sdk().url
   const auth = connection.http
   const username = auth?.username ?? "opencode"
@@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => {
     }
   }
 
-  const pushSize = (cols: number, rows: number) => {
-    return client.pty
-      .update({
+  const pushSize = async (cols: number, rows: number) => {
+    if ((await sdk().protocol) === "v1") {
+      return sdk()
+        .client.pty.update({
+          ptyID: id,
+          size: { cols, rows },
+        })
+        .catch((err) => {
+          debugTerminal("failed to sync terminal size", err)
+        })
+    }
+    return sdk()
+      .api.pty.update({
         ptyID: id,
+        location: { directory },
         size: { cols, rows },
       })
       .catch((err) => {
@@ -522,34 +532,54 @@ export const Terminal = (props: TerminalProps) => {
         local.onConnectError?.(err)
       }
 
-      const gone = () =>
-        client.pty
-          .get({ ptyID: id }, { throwOnError: false })
-          .then((result) => result.response.status === 404)
+      const gone = async () => {
+        if ((await sdk().protocol) === "v1") {
+          return sdk()
+            .client.pty.get({ ptyID: id }, { throwOnError: false })
+            .then((result) => result.response.status === 404)
+            .catch((err) => {
+              debugTerminal("failed to inspect terminal session", err)
+              return false
+            })
+        }
+        return sdk()
+          .api.pty.get({ ptyID: id, location: { directory } })
+          .then((result) => result.data.status === "exited")
           .catch((err) => {
+            if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
             debugTerminal("failed to inspect terminal session", err)
             return false
           })
+      }
 
       const connectToken = async () => {
-        const result = await client.pty
-          .connectToken(
-            { ptyID: id, directory },
-            {
-              throwOnError: false,
-              headers: { "x-opencode-ticket": "1" },
-            },
-          )
-          .catch((err: unknown) => {
-            if (err instanceof Error && err.message.includes("Request is not supported")) return
-            throw err
-          })
-        if (!result) return
-        if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
-        if (result.response.status === 404 || result.response.status === 405) return
-        if (result.response.status === 403)
-          throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
-        throw new Error(`PTY connect ticket failed with ${result.response.status}`)
+        if ((await sdk().protocol) === "v1") {
+          const result = await sdk()
+            .client.pty.connectToken(
+              { ptyID: id, directory },
+              {
+                throwOnError: false,
+                headers: { "x-opencode-ticket": "1" },
+              },
+            )
+            .catch((err: unknown) => {
+              if (err instanceof Error && err.message.includes("Request is not supported")) return
+              throw err
+            })
+          if (!result) return
+          if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
+          if (result.response.status === 404 || result.response.status === 405) return
+          if (result.response.status === 403)
+            throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
+          throw new Error(`PTY connect ticket failed with ${result.response.status}`)
+        }
+        // return sdk()
+        //   .api.pty.connectToken({
+        //     ptyID: id,
+        //     location: { directory },
+        //     "x-opencode-ticket": "1",
+        //   })
+        //   .then((result) => result.data.ticket)
       }
 
       const retry = (err: unknown) => {
@@ -579,11 +609,14 @@ export const Terminal = (props: TerminalProps) => {
           fail(err)
           return undefined
         })
+        const protocol = await sdk().protocol
+        // if (protocol === "v2" && !ticket) return
         if (once.value) return
         if (disposed) return
 
         const socket = new WebSocket(
           terminalWebSocketURL({
+            protocol,
             url,
             id,
             directory,

+ 2 - 2
packages/app/src/components/titlebar-tab-gesture.ts

@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
   if (typeof ref === "function") ref(element)
 }
 
-export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
-  return !dragging && !editing && !committing
+export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
+  return !dragging && !editing && !pending
 }

+ 80 - 106
packages/app/src/components/titlebar-tab-nav.tsx

@@ -1,14 +1,13 @@
 import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { createMutation } from "@tanstack/solid-query"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { useGlobal } from "@/context/global"
-import { useLanguage } from "@/context/language"
 import { ServerConnection, serverName } from "@/context/server"
 import { displayName, projectForSession } from "@/pages/layout/helpers"
 import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
-import { showToast } from "@/utils/toast"
 import type { Session } from "@opencode-ai/sdk/v2"
 import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
 import { TabPreviewPopover } from "./titlebar-tab-popover"
@@ -23,8 +22,7 @@ export function TabNavItem(props: {
   server: ServerConnection.Key
   session: () => Session | undefined
   fallbackTitle?: string
-  onTitleChange?: (title: string) => void
-  onTitleChangeFailed?: (title: string) => void
+  onRename: (title: string) => Promise<void>
   onClose: () => void
   onNavigate: () => void
   active?: boolean
@@ -34,13 +32,12 @@ export function TabNavItem(props: {
   pressed?: boolean
   hidden?: boolean
 }) {
-  const language = useLanguage()
   const [editing, setEditing] = createSignal(false)
   const [titleOverflowing, setTitleOverflowing] = createSignal(false)
   let tabRoot!: HTMLDivElement
   let titleEl!: HTMLSpanElement
-  let committing = false
   let measureFrame: number | undefined
+  const rename = createMutation(() => ({ mutationFn: props.onRename }))
 
   const closeTab = (event: MouseEvent) => {
     event.preventDefault()
@@ -116,41 +113,20 @@ export function TabNavItem(props: {
     selection?.addRange(range)
   }
 
-  const rename = async (title: string) => {
-    const ctx = serverCtx()
-    const session = props.session()
-    if (!ctx || !session) return
-    const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
-    await client.session.update({ sessionID: session.id, title })
-  }
-
   const closeRename = async (save: boolean) => {
-    if (committing || !editing()) return
-    committing = true
+    if (rename.isPending || !editing()) return
 
     const original = props.session()?.title ?? ""
     const next = (titleEl.textContent ?? "").trim()
 
     titleEl.scrollLeft = 0
-    if (save && next && next !== original) props.onTitleChange?.(next)
     setEditing(false)
 
     if (!save || !next || next === original) {
-      committing = false
       return
     }
 
-    try {
-      await rename(next)
-    } catch (err) {
-      props.onTitleChangeFailed?.(original)
-      showToast({
-        title: language.t("common.requestFailed"),
-        description: err instanceof Error ? err.message : undefined,
-      })
-    }
-
-    committing = false
+    await rename.mutateAsync(next)
   }
 
   createEffect(() => {
@@ -164,7 +140,7 @@ export function TabNavItem(props: {
   const openRename = (event: MouseEvent) => {
     event.preventDefault()
     event.stopPropagation()
-    if (!canOpenTabRename(props.dragging, editing(), committing)) return
+    if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
     const session = props.session()
     if (!session) return
     titleEl.textContent = session.title
@@ -219,88 +195,86 @@ export function TabNavItem(props: {
         closeTab(event)
       }}
     >
-      <Show when={title() !== undefined}>
-        <a
-          data-slot="tab-link"
-          data-titlebar-tab-link
-          href={props.href}
-          draggable={false}
-          onDragStart={(event) => {
-            event.preventDefault()
+      <a
+        data-slot="tab-link"
+        data-titlebar-tab-link
+        href={props.href}
+        draggable={false}
+        onDragStart={(event) => {
+          event.preventDefault()
+          event.stopPropagation()
+        }}
+        onMouseDown={(event) => {
+          // Navigate on mousedown to shave the press-release delay off tab switches.
+          if (event.button !== 0) return
+          if (editing()) return
+          if (props.suppressNavigation?.()) return
+          props.onNavigate()
+        }}
+        onClick={(event) => {
+          event.preventDefault()
+          // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
+          if (event.detail > 0) return
+          if (editing()) return
+          if (props.suppressNavigation?.()) return
+          props.onNavigate()
+        }}
+        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
+      >
+        <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
+          <Show
+            when={props.session()}
+            fallback={
+              <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
+            }
+          >
+            {(session) => (
+              <SessionTabAvatar
+                project={project()}
+                directory={session().directory}
+                sessionId={session().id}
+                server={props.server}
+              />
+            )}
+          </Show>
+        </span>
+        <span
+          ref={(el) => {
+            titleEl = el
+            titleEl.textContent = title() ?? ""
+          }}
+          data-slot="tab-title"
+          data-titlebar-tab-title
+          class="min-w-0 flex-1 outline-none leading-4"
+          classList={{
+            "overflow-hidden text-clip whitespace-nowrap": !editing(),
+            "select-text": editing(),
+          }}
+          contenteditable={editing() ? true : undefined}
+          onDblClick={openRename}
+          onKeyDown={(event) => {
             event.stopPropagation()
+            if (event.key === "Enter") {
+              event.preventDefault()
+              void closeRename(true)
+              return
+            }
+            if (event.key !== "Escape") return
+            event.preventDefault()
+            titleEl.textContent = props.session()?.title ?? ""
+            void closeRename(false)
           }}
-          onMouseDown={(event) => {
-            // Navigate on mousedown to shave the press-release delay off tab switches.
-            if (event.button !== 0) return
-            if (editing()) return
-            if (props.suppressNavigation?.()) return
-            props.onNavigate()
+          onBlur={() => void closeRename(true)}
+          onPointerDown={(event) => {
+            if (!editing()) return
+            event.stopPropagation()
           }}
           onClick={(event) => {
+            if (!editing()) return
             event.preventDefault()
-            // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
-            if (event.detail > 0) return
-            if (editing()) return
-            if (props.suppressNavigation?.()) return
-            props.onNavigate()
           }}
-          class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
-        >
-          <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
-            <Show
-              when={props.session()}
-              fallback={
-                <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
-              }
-            >
-              {(session) => (
-                <SessionTabAvatar
-                  project={project()}
-                  directory={session().directory}
-                  sessionId={session().id}
-                  server={props.server}
-                />
-              )}
-            </Show>
-          </span>
-          <span
-            ref={(el) => {
-              titleEl = el
-              titleEl.textContent = title() ?? ""
-            }}
-            data-slot="tab-title"
-            data-titlebar-tab-title
-            class="min-w-0 flex-1 outline-none leading-4"
-            classList={{
-              "overflow-hidden text-clip whitespace-nowrap": !editing(),
-              "select-text": editing(),
-            }}
-            contenteditable={editing() ? true : undefined}
-            onDblClick={openRename}
-            onKeyDown={(event) => {
-              event.stopPropagation()
-              if (event.key === "Enter") {
-                event.preventDefault()
-                void closeRename(true)
-                return
-              }
-              if (event.key !== "Escape") return
-              event.preventDefault()
-              titleEl.textContent = props.session()?.title ?? ""
-              void closeRename(false)
-            }}
-            onBlur={() => void closeRename(true)}
-            onPointerDown={(event) => {
-              if (!editing()) return
-              event.stopPropagation()
-            }}
-            onClick={(event) => {
-              if (!editing()) return
-              event.preventDefault()
-            }}
-          />
-        </a>
-      </Show>
+        />
+      </a>
 
       <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
         <IconButtonV2

+ 23 - 0
packages/app/src/components/titlebar-tab-order.test.ts

@@ -0,0 +1,23 @@
+import { describe, expect, test } from "bun:test"
+import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
+
+describe("adjacentTabKey", () => {
+  test("follows the visible left-to-right order", () => {
+    expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
+    expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
+  })
+
+  test("skips tabs omitted from the visible order", () => {
+    expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
+    expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
+  })
+})
+
+test("merges reordered visible tabs around hidden tabs", () => {
+  expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
+    "c",
+    "hidden",
+    "a",
+    "b",
+  ])
+})

+ 12 - 0
packages/app/src/components/titlebar-tab-order.ts

@@ -0,0 +1,12 @@
+export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
+  if (!current || order.length === 0) return
+  const index = order.indexOf(current)
+  if (index === -1) return
+  return order[(index + offset + order.length) % order.length]
+}
+
+export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
+  const visible = new Set(current)
+  const reordered = next.values()
+  return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
+}

+ 127 - 43
packages/app/src/components/titlebar-tab-strip.tsx

@@ -1,4 +1,5 @@
-import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
+import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
 import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -15,7 +16,10 @@ import { useCommand } from "@/context/command"
 import { useTabs } from "@/context/tabs"
 import { createTabPromptState } from "@/context/prompt"
 import { base64Encode } from "@opencode-ai/core/util/encode"
+import { showToast } from "@/utils/toast"
 import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
+import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
+import type { Session } from "@opencode-ai/sdk/v2"
 
 function SessionTabSlot(props: {
   tab: SessionTab
@@ -23,12 +27,12 @@ function SessionTabSlot(props: {
   index: () => number
   active: () => boolean
   forceTruncate: boolean
-  serverCtx: () => ServerCtx | undefined
+  session: () => Session | undefined
+  fallbackTitle?: string
+  onRename: (title: string) => Promise<void>
   onNavigate: (element: HTMLDivElement) => void
   onClose: () => void
 }) {
-  const tabs = useTabs()
-  const language = useLanguage()
   const sortable = useSortable({
     get id() {
       return props.id
@@ -38,6 +42,47 @@ function SessionTabSlot(props: {
     },
   })
   let ref!: HTMLDivElement
+
+  return (
+    <div
+      ref={sortable.ref}
+      data-titlebar-tab-slot
+      data-tab-key={props.id}
+      data-active={props.active()}
+      class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
+    >
+      <TabNavItem
+        ref={(el) => {
+          ref = el
+        }}
+        href={tabHref(props.tab)}
+        server={props.tab.server}
+        session={props.session}
+        fallbackTitle={props.fallbackTitle}
+        onRename={props.onRename}
+        onNavigate={() => props.onNavigate(ref)}
+        onClose={props.onClose}
+        active={props.active()}
+        forceTruncate={props.forceTruncate}
+        dragging={sortable.isDragSource()}
+      />
+    </div>
+  )
+}
+
+function SessionTabEntry(props: {
+  tab: SessionTab
+  id: string
+  index: () => number
+  active: () => boolean
+  forceTruncate: boolean
+  serverCtx: () => ServerCtx | undefined
+  onVisibleChange: (visible: boolean) => void
+  onNavigate: (element: HTMLDivElement) => void
+  onClose: () => void
+}) {
+  const tabs = useTabs()
+  const language = useLanguage()
   const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
   const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
   const persisted = createMemo(() => tabs.info[props.id])
@@ -50,8 +95,30 @@ function SessionTabSlot(props: {
   )
   const session = createMemo(() => cachedSession() ?? loadedSession())
   const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
+  const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
   let prefetched = false
 
+  const rename = async (title: string) => {
+    const value = session()
+    const ctx = props.serverCtx()
+    if (!value || !ctx) return
+
+    ctx.sync.session.remember({ ...value, title })
+    try {
+      await ctx.sdk.api.session.rename({ sessionID: value.id, title })
+    } catch (err) {
+      const current = session()
+      const currentCtx = props.serverCtx()
+      if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
+      showToast({
+        title: language.t("common.requestFailed"),
+        description: err instanceof Error ? err.message : undefined,
+      })
+    }
+  }
+
+  createEffect(() => props.onVisibleChange(visible()))
+
   createEffect(() => {
     const ctx = props.serverCtx()
     const value = session()
@@ -83,39 +150,20 @@ function SessionTabSlot(props: {
   })
 
   return (
-    <div
-      ref={sortable.ref}
-      data-titlebar-tab-slot
-      data-tab-key={props.id}
-      data-active={props.active()}
-      class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
-      classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
-    >
-      <TabNavItem
-        ref={(el) => {
-          ref = el
-        }}
-        href={tabHref(props.tab)}
-        server={props.tab.server}
+    <Show when={visible()}>
+      <SessionTabSlot
+        tab={props.tab}
+        id={props.id}
+        index={props.index}
+        active={props.active}
+        forceTruncate={props.forceTruncate}
         session={session}
         fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
-        onTitleChange={(title) => {
-          const value = session()
-          const ctx = props.serverCtx()
-          if (value && ctx) ctx.sync.session.remember({ ...value, title })
-        }}
-        onTitleChangeFailed={(title) => {
-          const value = session()
-          const ctx = props.serverCtx()
-          if (value && ctx) ctx.sync.session.remember({ ...value, title })
-        }}
-        onNavigate={() => props.onNavigate(ref)}
+        onRename={rename}
+        onNavigate={props.onNavigate}
         onClose={props.onClose}
-        active={props.active()}
-        forceTruncate={props.forceTruncate}
-        dragging={sortable.isDragSource()}
       />
-    </div>
+    </Show>
   )
 }
 
@@ -172,11 +220,39 @@ export function TitlebarTabStrip(props: {
 }) {
   const global = useGlobal()
   const language = useLanguage()
+  const command = useCommand()
   let scrollRef!: HTMLDivElement
   let listRef!: HTMLDivElement
   let resizeFrame: number | undefined
+  const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
+  const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
+  const visibleTabIds = () => visibleTabs().map(tabKey)
 
-  const tabIds = () => props.tabs.map(tabKey)
+  command.register("titlebar-tab-cycle", () => [
+    {
+      id: `tab.prev`,
+      category: "tab",
+      title: "",
+      keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
+      hidden: true,
+      onSelect: () => selectAdjacentTab(-1),
+    },
+    {
+      id: `tab.next`,
+      category: "tab",
+      title: "",
+      keybind: `mod+option+ArrowRight,ctrl+tab`,
+      hidden: true,
+      onSelect: () => selectAdjacentTab(1),
+    },
+  ])
+
+  function selectAdjacentTab(offset: -1 | 1) {
+    const current = props.currentTab()
+    const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
+    const next = props.tabs.find((tab) => tabKey(tab) === key)
+    if (next) props.onNavigate(next)
+  }
 
   function refreshOverflow() {
     if (!scrollRef) return
@@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: {
 
   createEffect(() => {
     props.tabs.length
-    tabIds()
+    visibleTabIds()
     refreshOverflow()
   })
 
@@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: {
             props.onNavigate(tab, tabEl ?? undefined)
           }}
           onDragEnd={(event) => {
-            const current = tabIds()
+            const current = visibleTabIds()
             const source = event.operation.source
             if (event.canceled || !isSortable(source)) return
 
             const { initialIndex, index } = source
             if (initialIndex !== index) {
-              props.onReorder(arrayMove(current, source.initialIndex, source.index))
+              props.onReorder(
+                mergeVisibleTabOrder(
+                  props.tabs.map(tabKey),
+                  current,
+                  arrayMove(current, source.initialIndex, source.index),
+                ),
+              )
             }
           }}
         >
           <div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
             <For each={props.tabs}>
-              {(tab, index) => {
+              {(tab) => {
                 const id = tabKey(tab)
                 let ref!: HTMLDivElement
-                useTabShortcut(index, () => props.onNavigate(tab, ref))
+                const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
+                useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
                 const serverCtx = createMemo(() => {
                   if (tab.type !== "session") return
                   const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: {
 
                 if (tab.type === "session") {
                   return (
-                    <SessionTabSlot
+                    <SessionTabEntry
                       tab={tab}
                       id={id}
-                      index={index}
+                      index={visibleIndex}
                       active={() => props.currentTab() === tab}
                       forceTruncate={props.forceTruncate}
                       serverCtx={serverCtx}
+                      onVisibleChange={(visible) => setVisibility(id, visible)}
                       onNavigate={(element) => {
                         ref = element
                         props.onNavigate(tab, element)
@@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: {
                   <DraftTabSlot
                     tab={tab}
                     id={id}
-                    index={index}
+                    index={visibleIndex}
                     active={() => props.currentTab() === tab}
                     title={language.t("command.session.new")}
                     onNavigate={(element) => {
@@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
 
   command.register(() => {
     const number = index() + 1
-    if (number > 9) return []
+    if (number < 1 || number > 9) return []
     return [
       {
         id: `tab.${number}`,

+ 13 - 128
packages/app/src/components/titlebar.tsx

@@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { useTheme } from "@opencode-ai/ui/theme/context"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -28,32 +27,13 @@ import { tabKey, useTabs } from "@/context/tabs"
 import type { PromptSession } from "@/context/prompt"
 import "./titlebar.css"
 import { newTabTooltipKeybind } from "./command-tooltip-keybind"
+import { normalizeSessionInfo } from "@/utils/session"
 
-type TauriDesktopWindow = {
-  startDragging?: () => Promise<void>
-  toggleMaximize?: () => Promise<void>
-}
-
-type TauriThemeWindow = {
-  setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
-}
-
-type TauriApi = {
-  window?: {
-    getCurrentWindow?: () => TauriDesktopWindow
-  }
-  webviewWindow?: {
-    getCurrentWebviewWindow?: () => TauriThemeWindow
-  }
-}
-
-const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
-const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
-const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
 const legacyTitlebarHeight = 40
 const v2TitlebarHeight = 36
 const minTitlebarZoom = 0.25
 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
+const macTrafficLightsBaseWidth = 84
 
 export type TitlebarUpdate = {
   version: () => string | undefined
@@ -73,7 +53,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
   const command = useCommand()
   const language = useLanguage()
   const settings = useSettings()
-  const theme = useTheme()
   const server = useServer()
   const navigate = useNavigate()
   const location = useLocation()
@@ -84,9 +63,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
 
   const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
   const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
-  const electronWindows = createMemo(() => windows() && !tauriApi())
   const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
   const web = createMemo(() => platform.platform === "web")
+  const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
   const zoom = () => platform.webviewZoom?.() ?? 1
   const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
   const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
@@ -175,56 +154,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
     },
   ])
 
-  const getWin = () => {
-    if (platform.platform !== "desktop") return
-    return currentDesktopWindow()
-  }
-
-  createEffect(() => {
-    if (platform.platform !== "desktop") return
-
-    const scheme = theme.colorScheme()
-    const value = scheme === "system" ? null : scheme
-
-    const win = currentThemeWindow()
-    if (!win?.setTheme) return
-
-    void win.setTheme(value).catch(() => undefined)
-  })
-
-  const interactive = (target: EventTarget | null) => {
-    if (!(target instanceof Element)) return false
-
-    const selector =
-      "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
-
-    return !!target.closest(selector)
-  }
-
-  const drag = (e: MouseEvent) => {
-    if (platform.platform !== "desktop") return
-    if (e.buttons !== 1) return
-    if (interactive(e.target)) return
-
-    const win = getWin()
-    if (!win?.startDragging) return
-
-    e.preventDefault()
-    void win.startDragging().catch(() => undefined)
-  }
-
-  const maximize = (e: MouseEvent) => {
-    if (platform.platform !== "desktop") return
-    if (interactive(e.target)) return
-    if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
-
-    const win = getWin()
-    if (!win?.toggleMaximize) return
-
-    e.preventDefault()
-    void win.toggleMaximize().catch(() => undefined)
-  }
-
   return (
     <header
       data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
@@ -237,16 +166,12 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
       style={{
         "min-height": minHeight(),
         // Keep native macOS traffic lights clear even when the desktop window is narrow.
-        "padding-left": mac() ? `${84 / zoom()}px` : 0,
-        width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
-        "max-width": electronWindows()
-          ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
-          : undefined,
-        "align-self": electronWindows() ? "flex-start" : undefined,
+        "padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
+        width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
+        "max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
+        "align-self": windows() ? "flex-start" : undefined,
       }}
       data-tauri-drag-region
-      onMouseDown={drag}
-      onDblClick={maximize}
     >
       <Switch>
         <Match when={useV2Titlebar()}>
@@ -267,9 +192,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
                 return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
               },
               ({ route, sdk }) =>
-                sdk.client.session
+                sdk.api.session
                   .get({ sessionID: route.sessionId })
-                  .then((x) => x.data)
+                  .then(normalizeSessionInfo)
                   .catch(() => {}),
             )
 
@@ -413,40 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
                   keybind: "mod+shift+t",
                   onSelect: () => tabsStoreActions.reopenClosedTab(),
                 },
-                {
-                  id: `tab.prev`,
-                  category: "tab",
-                  title: "",
-                  keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
-                  hidden: true,
-                  onSelect: () => {
-                    let index = tabsStore.findIndex((tab) => tab === currentTab())
-                    if (index === -1) return
-
-                    index -= 1
-                    if (index === -1) index = tabsStore.length - 1
-
-                    const next = tabsStore[index]
-                    if (next) tabs.select(next)
-                  },
-                },
-                {
-                  id: `tab.next`,
-                  category: "tab",
-                  title: "",
-                  keybind: `mod+option+ArrowRight,ctrl+tab`,
-                  hidden: true,
-                  onSelect: () => {
-                    let index = tabsStore.findIndex((tab) => tab === currentTab())
-                    if (index === -1) return
-
-                    index += 1
-                    if (index === tabsStore.length) index = 0
-
-                    const next = tabsStore[index]
-                    if (next) tabs.select(next)
-                  },
-                },
               ].filter((v) => v !== undefined)
             })
 
@@ -458,8 +349,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
                 classList={{
                   "pt-2": !bottom(),
                   "pb-2": bottom(),
-                  "md:pl-2": mac(),
-                  "md:pl-4": !mac(),
+                  "md:pl-2": macTrafficLights(),
+                  "md:pl-4": !macTrafficLights(),
                 }}
               >
                 <ChannelIndicator debugTools={props.debugTools} />
@@ -527,9 +418,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
                 </Show>
                 <div class="flex-1" />
                 <TitlebarV2Right state={v2RightState()} />
-                <Show when={windows() && !electronWindows()}>
-                  <div data-tauri-decorum-tb class="flex flex-row" />
-                </Show>
               </div>
             )
           }}
@@ -542,14 +430,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
             <div
               classList={{
                 "flex items-center min-w-0": true,
-                "pl-2": !mac(),
+                "pl-2": !macTrafficLights(),
               }}
             >
               <Show when={windows() || linux()}>
                 <WindowsAppMenu command={command} platform={platform} />
               </Show>
               <Show when={mac()}>
-                {/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
                 <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
                   <IconButton
                     icon="menu"
@@ -679,12 +566,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
                 "pr-2": !windows(),
               }}
               data-tauri-drag-region
-              onMouseDown={drag}
             >
               <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
               <Show when={windows()}>
-                {!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
-                <div data-tauri-decorum-tb class="flex flex-row" />
+                <div class="shrink-0" style={{ width: windowsControlsWidth() }} />
               </Show>
             </div>
           </div>

+ 22 - 7
packages/app/src/context/command.test.ts

@@ -1,5 +1,11 @@
 import { describe, expect, test } from "bun:test"
-import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
+import {
+  activeCommandRegistrations,
+  addCommandRegistration,
+  commandPaletteOptions,
+  resolveKeybindOption,
+  type CommandOption,
+} from "./command"
 
 const paletteOptions: CommandOption[] = [
   { id: "settings.open", title: "Open settings" },
@@ -15,22 +21,31 @@ describe("commandPaletteOptions", () => {
   })
 })
 
-describe("upsertCommandRegistration", () => {
-  test("replaces keyed registrations", () => {
+describe("command registrations", () => {
+  test("shadows keyed registrations while retaining the previous owner", () => {
     const one = () => [{ id: "one", title: "One" }]
     const two = () => [{ id: "two", title: "Two" }]
 
-    const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
+    const registrations = addCommandRegistration([{ key: "layout", options: one }], {
+      key: "layout",
+      options: two,
+    })
+    const active = activeCommandRegistrations(registrations)
 
-    expect(next).toHaveLength(1)
-    expect(next[0]?.options).toBe(two)
+    expect(registrations).toHaveLength(2)
+    expect(active).toHaveLength(1)
+    expect(active[0]?.options).toBe(two)
+
+    const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two))
+    expect(restored).toHaveLength(1)
+    expect(restored[0]?.options).toBe(one)
   })
 
   test("keeps unkeyed registrations additive", () => {
     const one = () => [{ id: "one", title: "One" }]
     const two = () => [{ id: "two", title: "Two" }]
 
-    const next = upsertCommandRegistration([{ options: one }], { options: two })
+    const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two }))
 
     expect(next).toHaveLength(2)
     expect(next[0]?.options).toBe(two)

+ 14 - 5
packages/app/src/context/command.tsx

@@ -114,9 +114,18 @@ export type CommandRegistration = {
   options: Accessor<CommandOption[]>
 }
 
-export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
-  if (entry.key === undefined) return [entry, ...registrations]
-  return [entry, ...registrations.filter((x) => x.key !== entry.key)]
+export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
+  return [entry, ...registrations]
+}
+
+export function activeCommandRegistrations(registrations: CommandRegistration[]) {
+  const keys = new Set<string>()
+  return registrations.filter((entry) => {
+    if (entry.key === undefined) return true
+    if (keys.has(entry.key)) return false
+    keys.add(entry.key)
+    return true
+  })
 }
 
 export function parseKeybind(config: string): Keybind[] {
@@ -281,7 +290,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       const seen = new Set<string>()
       const all: CommandOption[] = []
 
-      for (const reg of store.registrations) {
+      for (const reg of activeCommandRegistrations(store.registrations)) {
         for (const opt of reg.options()) {
           if (seen.has(opt.id)) {
             if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
@@ -425,7 +434,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
         key: id,
         options,
       }
-      setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
+      setStore("registrations", (arr) => addCommandRegistration(arr, entry))
       onCleanup(() => {
         setStore("registrations", (arr) => arr.filter((x) => x !== entry))
       })

+ 9 - 5
packages/app/src/context/directory-sync.ts

@@ -5,15 +5,18 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
 import type { createServerSdkContext } from "./server-sdk"
 import type { createServerSyncContextInner } from "./server-sync"
 import type { State } from "./global-sync/types"
+import { normalizeSessionInfo } from "@/utils/session"
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 const sessionFields = new Set([
   "session_status",
   "session_working",
   "session_diff",
+  "todo",
   "permission",
   "question",
   "message",
+  "session_message",
   "part",
   "part_text_accum_delta",
 ])
@@ -113,7 +116,7 @@ export const createDirSyncContext = (
         await serverSync.session.sync(sessionID, options)
         index(sessionID)
       },
-      diff: serverSync.session.diff,
+      todo: serverSync.session.todo,
       history: serverSync.session.history,
       evict(sessionID: string) {
         serverSync.session.evict(sessionID)
@@ -121,9 +124,9 @@ export const createDirSyncContext = (
       fetch: async (count = 10) => {
         const [store, setStore] = current()
         setStore("limit", (value) => value + count)
-        const response = await client.session.list()
-        const sessions = (response.data ?? [])
-          .filter((session) => !!session?.id)
+        const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
+        const sessions = response.data
+          .map(normalizeSessionInfo)
           .sort((a, b) => cmp(a.id, b.id))
           .slice(0, store.limit)
         sessions.forEach(serverSync.session.remember)
@@ -131,7 +134,8 @@ export const createDirSyncContext = (
       },
       more: createMemo(() => current()[0].session.length >= current()[0].limit),
       archive: async (sessionID: string) => {
-        await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
+        if ((await serverSDK.protocol) !== "v1") return
+        await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
         current()[1](
           "session",
           produce((draft) => {

+ 11 - 3
packages/app/src/context/file.tsx

@@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     }
 
     const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
-      sdk()
-        .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
+      serverSDK()
+        .api.file.find(
+          {
+            location: { directory: sdk().directory },
+            query,
+            type: dirs === "true" ? "directory" : "file",
+            limit: options?.limit,
+          },
+          { signal: options?.signal },
+        )
         .then(
-          (x) => (x.data ?? []).map(path.normalize),
+          (x) => x.data.map((entry) => path.normalize(entry.path)),
           (error) => {
             if (options?.signal?.aborted) throw error
             return []

+ 23 - 0
packages/app/src/context/file/path.test.ts

@@ -21,6 +21,29 @@ describe("file path helpers", () => {
     expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
   })
 
+  test("normalizes Windows directory separators", () => {
+    const path = createPathHelpers(() => "C:\\repo")
+    expect(path.normalizeDir("frontend\\")).toBe("frontend")
+    expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
+    expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend")
+  })
+
+  test("normalizes separators for Windows roots written with forward slashes", () => {
+    const path = createPathHelpers(() => "C:/repo")
+    expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
+  })
+
+  test("normalizes separators for Windows UNC roots", () => {
+    const path = createPathHelpers(() => "\\\\server\\share")
+    expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend")
+  })
+
+  test("preserves backslashes in POSIX directory names", () => {
+    const path = createPathHelpers(() => "/repo")
+    expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\")
+    expect(path.normalizeDir("literal\\name/")).toBe("literal\\name")
+  })
+
   test("keeps query/hash stripping behavior stable", () => {
     expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
     expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")

+ 6 - 1
packages/app/src/context/file/path.ts

@@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) {
     return normalize(tabValue)
   }
 
-  const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
+  const normalizeDir = (input: string) => {
+    const path = normalize(input)
+    const root = scope()
+    const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
+    return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "")
+  }
 
   return {
     normalize,

+ 5 - 5
packages/app/src/context/file/watcher.test.ts

@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
     const refresh: string[] = []
     invalidateFromWatcher(
       {
-        type: "filesystem.changed",
+        type: "file.watcher.updated",
         properties: {
           file: "src/new.ts",
           event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
 
     invalidateFromWatcher(
       {
-        type: "filesystem.changed",
+        type: "file.watcher.updated",
         properties: {
           file: "src/open.ts",
           event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
 
     invalidateFromWatcher(
       {
-        type: "filesystem.changed",
+        type: "file.watcher.updated",
         properties: {
           file: "src",
           event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
 
     invalidateFromWatcher(
       {
-        type: "filesystem.changed",
+        type: "file.watcher.updated",
         properties: {
           file: "src/file.ts",
           event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
 
     invalidateFromWatcher(
       {
-        type: "filesystem.changed",
+        type: "file.watcher.updated",
         properties: {
           file: ".git/index.lock",
           event: "change",

+ 1 - 1
packages/app/src/context/file/watcher.ts

@@ -16,7 +16,7 @@ type WatcherOps = {
 }
 
 export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
-  if (event.type !== "filesystem.changed") return
+  if (event.type !== "file.watcher.updated") return
   const props =
     typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
   const rawPath = typeof props?.file === "string" ? props.file : undefined

+ 139 - 71
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -1,14 +1,41 @@
 import { describe, expect, test } from "bun:test"
 import { createStore } from "solid-js/store"
 import { QueryClient } from "@tanstack/solid-query"
-import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
+import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
+import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
 import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
-import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
+import {
+  bootstrapDirectory,
+  loadAgentsQuery,
+  loadCommands,
+  loadPathQuery,
+  loadProjectsQuery,
+  loadProvidersQuery,
+  loadReferencesQuery,
+} from "./bootstrap"
 import type { State, VcsCache } from "./types"
-import { createServerSession } from "../server-session"
 import { ServerScope } from "@/utils/server-scope"
+import type { ServerApi } from "@/utils/server"
+
+type ProjectApi = ServerApi["project"]
 
 const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
+const api = {
+  agent: { list: async () => ({ location: {}, data: [] }) },
+  provider: { list: async () => ({ location: {}, data: [] }) },
+  model: {
+    list: async () => ({ location: {}, data: [] }),
+    default: async () => ({ location: {}, data: null }),
+  },
+  permission: { request: { list: async () => ({ location: {}, data: [] }) } },
+  project: {
+    list: async () => [],
+    current: async () => ({ id: "project", directory: "/project" }),
+  },
+  question: { request: { list: async () => ({ location: {}, data: [] }) } },
+  reference: { list: async () => ({ location: {}, data: [] }) },
+  vcs: { get: async () => ({ location: {}, data: {} }) },
+} as unknown as ServerApi
 
 function directoryState() {
   return createStore<State>({
@@ -30,6 +57,7 @@ function directoryState() {
       return this.session_status[id]?.type !== "idle"
     },
     session_diff: {},
+    todo: {},
     permission: {},
     question: {},
     mcp_ready: true,
@@ -40,20 +68,21 @@ function directoryState() {
     vcs: undefined,
     limit: 5,
     message: {},
+    session_message: {},
     part: {},
     part_text_accum_delta: {},
   })
 }
 
 describe("bootstrapDirectory", () => {
-  test("marks a loading directory partial during bootstrap and complete after success", async () => {
+  test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
     const mcpReads: string[] = []
     const [store, setStore] = directoryState()
 
     await bootstrapDirectory({
       directory: "/project",
       scope: ServerScope.local,
-      mcp: false,
+      mcp: true,
       global: {
         config: {} satisfies Config,
         path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -80,14 +109,24 @@ describe("bootstrapDirectory", () => {
             return { data: {} }
           },
         },
+        experimental: {
+          resource: {
+            list: async () => {
+              mcpReads.push("resource")
+              return { data: {} }
+            },
+          },
+        },
         provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
       } as unknown as OpencodeClient,
+      api,
       store,
       setStore,
       vcsCache: { setStore() {} } as unknown as VcsCache,
       loadSessions() {},
       translate: (key) => key,
       queryClient: new QueryClient(),
+      protocol: Promise.resolve("v1"),
     })
 
     expect(store.status).toBe("partial")
@@ -95,81 +134,110 @@ describe("bootstrapDirectory", () => {
     await new Promise((resolve) => setTimeout(resolve, 80))
 
     expect(store.status).toBe("complete")
-    expect(mcpReads).toEqual([])
-  })
-
-  test("seeds session status even while warming session info stalls", async () => {
-    const [store, setStore] = directoryState()
-    const stalled = Promise.withResolvers<never>()
-    const client = {
-      app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
-      config: { get: async () => ({ data: {} }) },
-      session: {
-        status: async () => ({ data: { ses_busy: { type: "busy" } } }),
-        get: () => stalled.promise,
-      },
-      vcs: { get: async () => ({ data: undefined }) },
-      command: { list: async () => ({ data: [] }) },
-      permission: { list: async () => ({ data: [] }) },
-      question: { list: async () => ({ data: [] }) },
-      v2: { reference: { list: async () => ({ data: { data: [] } }) } },
-      mcp: { status: async () => ({ data: {} }) },
-      provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
-    } as unknown as OpencodeClient
-    const session = createServerSession(client)
-    const stale: Session = {
-      id: "ses_stale",
-      slug: "ses_stale",
-      projectID: "project",
-      directory: "/project",
-      title: "stale",
-      version: "1",
-      time: { created: 1, updated: 1 },
-    }
-    session.remember(stale)
-    session.set("session_status", stale.id, { type: "busy" })
-
-    await bootstrapDirectory({
-      directory: "/project",
-      scope: ServerScope.local,
-      mcp: false,
-      global: {
-        config: {} satisfies Config,
-        path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
-        project: [{ id: "project", worktree: "/project" } as Project],
-        provider,
-      },
-      sdk: client,
-      store,
-      setStore,
-      vcsCache: { setStore() {} } as unknown as VcsCache,
-      loadSessions() {},
-      translate: (key) => key,
-      queryClient: new QueryClient(),
-      session,
-    })
-
-    const deadline = Date.now() + 500
-    while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
-      await new Promise((resolve) => setTimeout(resolve, 10))
-    }
-
-    expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
-    expect(session.data.session_status[stale.id]).toBeUndefined()
+    expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
   })
 })
 
 describe("query keys", () => {
   test("partitions identical directories by server scope", () => {
-    const client = {} as OpencodeClient
+    const client = {} as Parameters<typeof loadPathQuery>[2]
+    const api = {} as CatalogApi
     const remote = "https://debian.example" as typeof ServerScope.local
 
     expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
     expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
-    expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
-      "https://debian.example",
-      null,
-      "providers",
+    expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
+  })
+
+  test("loads the current provider and model catalog", async () => {
+    const calls: unknown[] = []
+    const api = {
+      provider: {
+        list: async (input: unknown) => {
+          calls.push(["provider", input])
+          return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
+        },
+      },
+      model: {
+        list: async (input: unknown) => {
+          calls.push(["model", input])
+          return { location: {}, data: [] }
+        },
+        default: async (input: unknown) => {
+          calls.push(["default", input])
+          return { location: {}, data: null }
+        },
+      },
+    } as unknown as CatalogApi
+
+    const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
+
+    expect(calls).toEqual([
+      ["provider", { location: { directory: "/repo" } }],
+      ["model", { location: { directory: "/repo" } }],
+      ["default", { location: { directory: "/repo" } }],
     ])
+    expect(result.connected).toEqual(["openai"])
+  })
+
+  test("loads agents from the current location-scoped endpoint", async () => {
+    const calls: unknown[] = []
+    const api = {
+      list: async (input: unknown) => {
+        calls.push(input)
+        return { location: {}, data: [] }
+      },
+    } as unknown as AgentApi
+
+    const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
+
+    expect(calls).toEqual([{ location: { directory: "/repo" } }])
+    expect(result).toEqual([])
+  })
+
+  test("loads commands from the current location-scoped endpoint", async () => {
+    const calls: unknown[] = []
+    const api = {
+      list: async (input: unknown) => {
+        calls.push(input)
+        return {
+          location: {},
+          data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
+        }
+      },
+    } as unknown as CommandApi
+
+    const result = await loadCommands("/repo", api)
+
+    expect(calls).toEqual([{ location: { directory: "/repo" } }])
+    expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
+  })
+
+  test("loads projects from the current endpoint", async () => {
+    const api = {
+      list: async () => [
+        { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
+        { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
+      ],
+    } as unknown as ProjectApi
+
+    const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
+
+    expect(result.map((project) => project.id)).toEqual(["a", "b"])
+  })
+
+  test("loads references from the current location-scoped endpoint", async () => {
+    const calls: unknown[] = []
+    const api = {
+      list: async (input: unknown) => {
+        calls.push(input)
+        return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
+      },
+    } as unknown as ReferenceApi
+
+    const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
+
+    expect(calls).toEqual([{ location: { directory: "/repo" } }])
+    expect(result).toHaveLength(1)
   })
 })

+ 223 - 56
packages/app/src/context/global-sync/bootstrap.ts

@@ -9,6 +9,20 @@ import type {
   ReferenceInfo,
   Session,
 } from "@opencode-ai/sdk/v2/client"
+import type {
+  AgentListInput,
+  AgentListOutput,
+  CatalogApi,
+  CommandInfo,
+  CommandListInput,
+  CommandListOutput,
+  ProjectCurrentInput,
+  ProjectCurrentOutput,
+  ProjectListOutput,
+  ReferenceListInput,
+  ReferenceListOutput,
+  SessionApi,
+} from "@opencode-ai/client/promise"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { retry } from "@opencode-ai/core/util/retry"
@@ -16,12 +30,21 @@ import { batch } from "solid-js"
 import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
 import type { ServerSession } from "../server-session"
-import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
+import {
+  cmp,
+  normalizeAgentList,
+  normalizePermissionRequest,
+  normalizeProjectInfo,
+  normalizeProviderList,
+} from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 import { QueryClient, queryOptions } from "@tanstack/solid-query"
 import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { ScopedKey, type ServerScope } from "@/utils/server-scope"
+import { normalizeSessionInfo } from "@/utils/session"
+import type { ServerProtocol } from "@/utils/server-protocol"
+import type { ServerApi } from "@/utils/server"
 
 type GlobalStore = {
   ready: boolean
@@ -88,15 +111,26 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
     queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
   })
 
-export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
+type ProjectApi = {
+  readonly list: () => Promise<ProjectListOutput>
+  readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
+}
+
+type McpApi = ServerApi["mcp"]
+type PermissionApi = ServerApi["permission"]
+type QuestionApi = ServerApi["question"]
+type VcsApi = ServerApi["vcs"]
+
+export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
   queryOptions({
     queryKey: [scope, "project"],
     queryFn: () =>
       retry(() =>
-        sdk.project.list().then((x) => {
-          return (x.data ?? [])
+        api.list().then((projects) => {
+          return projects
             .filter((p) => !!p?.id)
             .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+            .map(normalizeProjectInfo)
             .slice()
             .sort((a, b) => cmp(a.id, b.id))
         }),
@@ -105,6 +139,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
 
 export async function bootstrapGlobal(input: {
   serverSDK: OpencodeClient
+  serverAPI: CatalogApi & { readonly project: ProjectApi }
+  protocol?: Promise<ServerProtocol>
   scope: ServerScope
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
@@ -114,11 +150,14 @@ export async function bootstrapGlobal(input: {
 }) {
   const slow = [
     () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
+    () =>
+      input.queryClient.fetchQuery(
+        loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
+      ),
+    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
     () =>
       input.queryClient
-        .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
+        .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
         .then((data) => input.setGlobalStore("project", data)),
   ]
   await runAll(slow)
@@ -162,44 +201,126 @@ function warmSessions(input: {
   ids: string[]
   store: Store<State>
   setStore: SetStoreFunction<State>
-  sdk: OpencodeClient
+  api: SessionApi
 }) {
   const known = new Set(input.store.session.map((item) => item.id))
   const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
   if (ids.length === 0) return Promise.resolve()
   return Promise.all(
     ids.map((sessionID) =>
-      retry(() => input.sdk.session.get({ sessionID })).then((x) => {
-        const session = x.data
-        if (!session?.id) return
-        mergeSession(input.setStore, session)
-      }),
+      retry(() => input.api.get({ sessionID })).then((session) =>
+        mergeSession(input.setStore, normalizeSessionInfo(session)),
+      ),
     ),
   ).then(() => undefined)
 }
 
-export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+export const loadProvidersQuery = (
+  scope: ServerScope,
+  directory: string | null,
+  sdk: CatalogApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions({
     queryKey: [scope, directory, "providers"],
-    queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
+    queryFn: () =>
+      retry(async () => {
+        if ((await protocol) === "v1" && legacy) {
+          const result = await legacy.provider.list()
+          return normalizeProviderList(result.data!)
+        }
+        const location = directory ? { location: { directory } } : undefined
+        const [providers, models, defaultModel] = await Promise.all([
+          sdk.provider.list(location),
+          sdk.model.list(location),
+          sdk.model.default(location),
+        ])
+        return normalizeProviderList(providers.data, models.data, defaultModel.data)
+      }),
   })
 
-export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+type AgentListApi = {
+  readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
+}
+
+type CommandListApi = {
+  readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
+}
+
+type ReferenceListApi = {
+  readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
+}
+
+export const loadAgentsQuery = (
+  scope: ServerScope,
+  directory: string,
+  sdk: AgentListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions({
     queryKey: [scope, directory, "agents"],
-    queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
+    queryFn: () =>
+      retry(async () => {
+        if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
+        return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
+      }),
   })
 
-export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+export const loadCommands = (
+  directory: string,
+  api: CommandListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+): Promise<CommandInfo[]> =>
+  retry(async () => {
+    if ((await protocol) === "v1" && legacy) {
+      return ((await legacy.command.list()).data ?? []).map((command) => {
+        const [providerID, id] = command.model?.split("/") ?? []
+        return {
+          name: command.name,
+          template: command.template,
+          description: command.description,
+          agent: command.agent,
+          model: providerID && id ? { providerID, id } : undefined,
+          subtask: command.subtask,
+          // source: command.source === "skill" ? undefined : command.source,
+        }
+      })
+    }
+    return api.list({ location: { directory } }).then((result) => result.data)
+  })
+
+export const loadPathQuery = (
+  scope: ServerScope,
+  directory: string | null,
+  sdk: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions<Path>({
     queryKey: [scope, directory, "path"],
-    queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
+    queryFn: async () => {
+      if ((await protocol) !== "v1")
+        return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
+      return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
+    },
   })
 
-export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
+export const loadReferencesQuery = (
+  scope: ServerScope,
+  directory: string,
+  api: ReferenceListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions<ReferenceInfo[]>({
     queryKey: [scope, directory, "references"] as const,
-    queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
+    queryFn: () =>
+      retry(async () => {
+        if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
+        return api.list({ location: { directory } }).then((result) => result.data)
+      }).catch(() => []),
     placeholderData: [],
   })
 
@@ -208,6 +329,17 @@ export async function bootstrapDirectory(input: {
   scope: ServerScope
   mcp: boolean
   sdk: OpencodeClient
+  api: CatalogApi & {
+    readonly agent: AgentListApi
+    readonly command: CommandListApi
+    readonly mcp: McpApi
+    readonly permission: PermissionApi
+    readonly project: ProjectApi
+    readonly question: QuestionApi
+    readonly reference: ReferenceListApi
+    readonly session: SessionApi
+    readonly vcs: VcsApi
+  }
   store: Store<State>
   setStore: SetStoreFunction<State>
   vcsCache: VcsCache
@@ -221,6 +353,7 @@ export async function bootstrapDirectory(input: {
   }
   queryClient: QueryClient
   session?: ServerSession
+  protocol?: Promise<ServerProtocol>
 }) {
   const loading = input.store.status !== "complete"
   const seededProject = projectID(input.directory, input.global.project)
@@ -240,13 +373,15 @@ export async function bootstrapDirectory(input: {
       () => Promise.resolve(input.loadSessions(input.directory)),
       () =>
         input.queryClient
-          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
+          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
           .then((data) => input.setStore("agent", data)),
       () =>
         retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
       () =>
         retry(() =>
-          input.sdk.session.status().then(async (x) => {
+          (async () => {
+            if ((await input.protocol) !== "v1") return
+            const x = await input.sdk.session.status()
             if (!input.session) {
               input.setStore("session_status", x.data!)
               return
@@ -264,42 +399,57 @@ export async function bootstrapDirectory(input: {
             for (const [sessionID, status] of Object.entries(statuses)) {
               input.session.set("session_status", sessionID, reconcile(status))
             }
-            // Warm session info only after seeding statuses so a stalled session
-            // fetch cannot park busy indicators behind it, mirroring how live
-            // session.status events apply first and resolve info in the background.
             await Promise.all(
               Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
             )
-          }),
+          })(),
         ),
       !seededProject &&
-        (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
+        (() =>
+          retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
+            input.setStore("project", project.id),
+          )),
       !seededPath &&
         (() =>
-          input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
-            const next = projectID(data.directory ?? input.directory, input.global.project)
-            if (next) input.setStore("project", next)
-          })),
+          input.queryClient
+            .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
+            .then((data) => {
+              const next = projectID(data.directory ?? input.directory, input.global.project)
+              if (next) input.setStore("project", next)
+            })),
       () =>
-        retry(() =>
-          input.sdk.vcs.get().then((x) => {
-            const next = x.data ?? input.store.vcs
+        retry(async () => {
+          if ((await input.protocol) !== "v1") return
+          return input.sdk.vcs.get().then((result) => {
+            const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
             input.setStore("vcs", next)
             if (next) input.vcsCache.setStore("value", next)
-          }),
+          })
+        }),
+      input.mcp &&
+        (() =>
+          loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
+            input.setStore("command", commands),
+          )),
+      () =>
+        input.queryClient.fetchQuery(
+          loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
         ),
-      input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
-      () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
       () =>
         retry(() =>
-          input.sdk.permission.list().then((x) => {
-            const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
+          (async () => {
+            if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
+            return input.api.permission.request
+              .list({ location: { directory: input.directory } })
+              .then((result) => result.data.map(normalizePermissionRequest))
+          })().then((permissions) => {
+            const ids = permissions.map((permission) => permission.sessionID)
             const grouped = groupBySession(
-              (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+              permissions.filter((permission) => !!permission.id && !!permission.sessionID),
             )
             const warm = input.session
               ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
-              : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
+              : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
             return warm.then(() =>
               batch(() => {
                 const current = input.session?.data.permission ?? input.store.permission
@@ -323,12 +473,19 @@ export async function bootstrapDirectory(input: {
         ),
       () =>
         retry(() =>
-          input.sdk.question.list().then((x) => {
-            const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
-            const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+          (async () => {
+            if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
+            return input.api.question.request
+              .list({ location: { directory: input.directory } })
+              .then((result) => result.data)
+          })().then((questions) => {
+            const ids = questions.map((question) => question.sessionID)
+            const grouped = groupBySession(
+              questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
+            )
             const warm = input.session
               ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
-              : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
+              : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
             return warm.then(() =>
               batch(() => {
                 const current = input.session?.data.question ?? input.store.question
@@ -351,17 +508,27 @@ export async function bootstrapDirectory(input: {
           }),
         ),
       () => Promise.resolve(input.loadSessions(input.directory)),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
+      input.mcp &&
+        (() =>
+          input.queryClient.fetchQuery(
+            loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
+          )),
+      input.mcp &&
+        (() =>
+          input.queryClient.fetchQuery(
+            loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
+          )),
       () =>
-        input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
-          const project = getFilename(input.directory)
-          showToast({
-            variant: "error",
-            title: input.translate("toast.project.reloadFailed.title", { project }),
-            description: formatServerError(err, input.translate),
-          })
-        }),
+        input.queryClient
+          .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
+          .catch((err) => {
+            const project = getFilename(input.directory)
+            showToast({
+              variant: "error",
+              title: input.translate("toast.project.reloadFailed.title", { project }),
+              description: formatServerError(err, input.translate),
+            })
+          }),
     ].filter(Boolean) as (() => Promise<any>)[]
 
     await waitForPaint()

+ 52 - 0
packages/app/src/context/global-sync/child-store.test.ts

@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
       dispose()
     }
   })
+
+  test("keeps non-bootstrapping children passive until a real directory access", () => {
+    let manager: ReturnType<typeof createChildStoreManager> | undefined
+    const offset = querySingles.length
+    const bootstraps: string[] = []
+
+    const dispose = createOwner((owner) => {
+      manager = createChildStoreManager({
+        owner,
+        scope: ServerScope.local,
+        persist,
+        isBooting: () => false,
+        isLoadingSessions: () => false,
+        onBootstrap(directory) {
+          bootstraps.push(directory)
+        },
+        onMcp() {},
+        onDispose() {},
+        translate: (key) => key,
+        queryOptions: queryOptionsApi,
+        global: { provider },
+      })
+    })
+
+    try {
+      if (!manager) throw new Error("manager required")
+      const [store] = manager.child("/project", { bootstrap: false })
+      const queries = querySingles.slice(offset)
+
+      expect(queries).toHaveLength(6)
+      expect(queries[0]?.().enabled).toBe(false)
+      expect(queries[3]?.().enabled).toBe(false)
+      expect(queries[4]?.().enabled).toBe(false)
+      expect(queries[5]?.().enabled).toBe(false)
+      expect(store.path.directory).toBe("/project")
+      expect(store.provider_ready).toBe(false)
+      expect(store.lsp_ready).toBe(false)
+      expect(bootstraps).toEqual([])
+
+      manager.child("/project")
+      expect(queries[0]?.().enabled).toBe(true)
+      expect(queries[3]?.().enabled).toBe(true)
+      expect(queries[4]?.().enabled).toBe(true)
+      expect(queries[5]?.().enabled).toBe(true)
+      expect(bootstraps).toEqual(["/project"])
+
+      manager.child("/project", { bootstrap: false })
+      expect(queries[0]?.().enabled).toBe(true)
+    } finally {
+      dispose()
+    }
+  })
 })

+ 33 - 6
packages/app/src/context/global-sync/child-store.ts

@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
   const disposers = new Map<string, () => void>()
   const mcpDirectories = new Set<string>()
   const mcpToggles = new Map<string, (enabled: boolean) => void>()
+  const activeDirectories = new Set<string>()
+  const activationToggles = new Map<string, (enabled: boolean) => void>()
 
   const markKey = (key: DirectoryKey) => {
     if (!key) return
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
     lifecycle.delete(key)
     mcpDirectories.delete(key)
     mcpToggles.delete(key)
+    activeDirectories.delete(key)
+    activationToggles.delete(key)
     const dispose = disposers.get(key)
     if (dispose) {
       dispose()
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
           const initialMeta = meta[0].value
           const initialIcon = icon[0].value
           const [mcpEnabled, setMcpEnabled] = createSignal(false)
+          const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
 
-          const pathQuery = useQuery(() => input.queryOptions.path(key))
+          const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
           const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
           const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
-          const lspQuery = useQuery(() => input.queryOptions.lsp(key))
-          const providerQuery = useQuery(() => input.queryOptions.providers(key))
-          const referenceQuery = useQuery(() => input.queryOptions.references(key))
+          const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
+          const providerQuery = useQuery(() => ({
+            ...input.queryOptions.providers(key),
+            enabled: instanceQueriesEnabled(),
+          }))
+          const referenceQuery = useQuery(() => ({
+            ...input.queryOptions.references(key),
+            enabled: instanceQueriesEnabled(),
+          }))
 
           const child = createStore<State>({
             project: "",
             projectMeta: initialMeta,
             icon: initialIcon,
             get provider_ready() {
-              return !providerQuery.isLoading
+              return instanceQueriesEnabled() && !providerQuery.isLoading
             },
             get provider() {
               const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -223,6 +234,7 @@ export function createChildStoreManager(input: {
               return (type ?? "idle") !== "idle"
             },
             session_diff: {},
+            todo: {},
             permission: {},
             question: {},
             get mcp_ready() {
@@ -235,7 +247,7 @@ export function createChildStoreManager(input: {
               return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
             },
             get lsp_ready() {
-              return !lspQuery.isLoading
+              return instanceQueriesEnabled() && !lspQuery.isLoading
             },
             get lsp() {
               return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -243,12 +255,14 @@ export function createChildStoreManager(input: {
             vcs: vcsStore.value,
             limit: 5,
             message: {},
+            session_message: {},
             part: {},
             part_text_accum_delta: {},
           })
           children[key] = child
           disposers.set(key, dispose)
           mcpToggles.set(key, setMcpEnabled)
+          activationToggles.set(key, setInstanceQueriesEnabled)
 
           const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
             if (!(init instanceof Promise)) return
@@ -289,6 +303,7 @@ export function createChildStoreManager(input: {
     pinForOwner(key)
     if (options.mcp) enableMcp(directory, key, childStore)
     const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap) activate(key)
     if (shouldBootstrap && childStore[0].status === "loading") {
       input.onBootstrap(directory)
     }
@@ -300,6 +315,7 @@ export function createChildStoreManager(input: {
     const childStore = ensureChild(directory)
     if (options.mcp) enableMcp(directory, key, childStore)
     const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap) activate(key)
     if (shouldBootstrap && childStore[0].status === "loading") {
       input.onBootstrap(directory)
     }
@@ -313,6 +329,16 @@ export function createChildStoreManager(input: {
     if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
   }
 
+  // Passive Home/project metadata reads must not initialize the directory.
+  // A real directory access enables these queries once for the store lifetime.
+  // TODO(v2): After Home switches to v2.project.list and root-filtered,
+  // updated-time v2.session.list, remove any Home-only passive child creation.
+  function activate(key: DirectoryKey) {
+    if (activeDirectories.has(key)) return
+    activeDirectories.add(key)
+    activationToggles.get(key)?.(true)
+  }
+
   function disableMcp(directory: string) {
     const key = directoryKey(directory)
     if (!mcpDirectories.delete(key)) return
@@ -359,6 +385,7 @@ export function createChildStoreManager(input: {
     unpin,
     pinned,
     mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
+    active: (directory: string) => activeDirectories.has(directoryKey(directory)),
     disableMcp,
     disposeDirectory,
     runEviction,

+ 36 - 3
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -72,6 +72,7 @@ const baseState = (input: Partial<State> = {}) =>
     sessionTotal: 0,
     session_status: {},
     session_diff: {},
+    todo: {},
     permission: {},
     question: {},
     mcp: {},
@@ -79,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
     vcs: undefined,
     limit: 10,
     message: {},
+    session_message: {},
     part: {},
     part_text_accum_delta: {},
     ...input,
@@ -215,6 +217,7 @@ describe("applyDirectoryEvent", () => {
         message: { ses_1: [message] },
         part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
         session_diff: { ses_1: [] },
+        todo: { ses_1: [] },
         permission: { ses_1: [] },
         question: { ses_1: [] },
         session_status: { ses_1: { type: "busy" } },
@@ -235,15 +238,32 @@ describe("applyDirectoryEvent", () => {
     expect(store.message.ses_1).toBeUndefined()
     expect(store.part[message.id]).toBeUndefined()
     expect(store.session_diff.ses_1).toBeUndefined()
+    expect(store.todo.ses_1).toBeUndefined()
     expect(store.permission.ses_1).toBeUndefined()
     expect(store.question.ses_1).toBeUndefined()
     expect(store.session_status.ses_1).toBeUndefined()
   })
 
+  test("ignores an archived session absent from a passive directory store", () => {
+    const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
+
+    applyDirectoryEvent({
+      event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
+      store,
+      setStore,
+      push() {},
+      directory: "/tmp",
+      loadLsp() {},
+    })
+
+    expect(store.session).toEqual([])
+    expect(store.sessionTotal).toBe(0)
+  })
+
   test("cleans session caches when deleted and decrements only root totals", () => {
     const cases = [
-      { info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
-      { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
+      { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
+      { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
     ]
 
     for (const item of cases) {
@@ -259,6 +279,7 @@ describe("applyDirectoryEvent", () => {
           message: { [item.info.id]: [message] },
           part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
           session_diff: { [item.info.id]: [] },
+          todo: { [item.info.id]: [] },
           permission: { [item.info.id]: [] },
           question: { [item.info.id]: [] },
           session_status: { [item.info.id]: { type: "busy" } },
@@ -266,7 +287,10 @@ describe("applyDirectoryEvent", () => {
       )
 
       applyDirectoryEvent({
-        event: { type: "session.deleted", properties: { info: item.info } },
+        event: {
+          type: "session.deleted",
+          properties: item.current ? { sessionID: item.info.id } : { info: item.info },
+        },
         store,
         setStore,
         push() {},
@@ -279,6 +303,7 @@ describe("applyDirectoryEvent", () => {
       expect(store.message[item.info.id]).toBeUndefined()
       expect(store.part[message.id]).toBeUndefined()
       expect(store.session_diff[item.info.id]).toBeUndefined()
+      expect(store.todo[item.info.id]).toBeUndefined()
       expect(store.permission[item.info.id]).toBeUndefined()
       expect(store.question[item.info.id]).toBeUndefined()
       expect(store.session_status[item.info.id]).toBeUndefined()
@@ -289,6 +314,7 @@ describe("applyDirectoryEvent", () => {
     const dropped = rootSession({ id: "ses_b" })
     const kept = rootSession({ id: "ses_a" })
     const message = userMessage("msg_1", dropped.id)
+    const todos: string[] = []
     const [store, setStore] = createStore(
       baseState({
         limit: 1,
@@ -296,6 +322,7 @@ describe("applyDirectoryEvent", () => {
         message: { [dropped.id]: [message] },
         part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
         session_diff: { [dropped.id]: [] },
+        todo: { [dropped.id]: [] },
         permission: { [dropped.id]: [] },
         question: { [dropped.id]: [] },
         session_status: { [dropped.id]: { type: "busy" } },
@@ -309,15 +336,21 @@ describe("applyDirectoryEvent", () => {
       push() {},
       directory: "/tmp",
       loadLsp() {},
+      setSessionTodo(sessionID, value) {
+        if (value !== undefined) return
+        todos.push(sessionID)
+      },
     })
 
     expect(store.session.map((x) => x.id)).toEqual([kept.id])
     expect(store.message[dropped.id]).toBeUndefined()
     expect(store.part[message.id]).toBeUndefined()
     expect(store.session_diff[dropped.id]).toBeUndefined()
+    expect(store.todo[dropped.id]).toBeUndefined()
     expect(store.permission[dropped.id]).toBeUndefined()
     expect(store.question[dropped.id]).toBeUndefined()
     expect(store.session_status[dropped.id]).toBeUndefined()
+    expect(todos).toEqual([dropped.id])
   })
 
   test("cleanupDroppedSessionCaches clears part-only orphan state", () => {

+ 108 - 20
packages/app/src/context/global-sync/event-reducer.ts

@@ -8,8 +8,9 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
+  Todo,
 } from "@opencode-ai/sdk/v2/client"
-import type { SessionDiff } from "@/utils/diffs"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
@@ -18,6 +19,7 @@ import { diffs as list, message as clean } from "@/utils/diffs"
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 const SESSION_CONTENT_EVENTS = new Set([
   "session.diff",
+  "todo.updated",
   "session.status",
   "message.updated",
   "message.removed",
@@ -60,8 +62,13 @@ export function applyGlobalEvent(input: {
   )
 }
 
-function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
+function cleanupSessionCaches(
+  setStore: SetStoreFunction<State>,
+  sessionID: string,
+  setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
+) {
   if (!sessionID) return
+  setSessionTodo?.(sessionID, undefined)
   setStore(
     produce((draft) => {
       dropSessionCaches(draft, [sessionID])
@@ -69,11 +76,17 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
   )
 }
 
-export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
+export function cleanupDroppedSessionCaches(
+  store: Store<State>,
+  setStore: SetStoreFunction<State>,
+  next: Session[],
+  setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
+) {
   const keep = new Set(next.map((item) => item.id))
   const stale = [
     ...Object.keys(store.message),
     ...Object.keys(store.session_diff),
+    ...Object.keys(store.todo),
     ...Object.keys(store.permission),
     ...Object.keys(store.question),
     ...Object.keys(store.session_status),
@@ -82,6 +95,9 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
       .filter((sessionID): sessionID is string => !!sessionID),
   ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
   if (stale.length === 0) return
+  for (const sessionID of stale) {
+    setSessionTodo?.(sessionID, undefined)
+  }
   setStore(
     produce((draft) => {
       dropSessionCaches(draft, stale)
@@ -98,6 +114,7 @@ export function applyDirectoryEvent(input: {
   loadLsp: () => void
   loadReferences?: () => void
   vcsCache?: VcsCache
+  setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
   retainedLimit?: number
   sessionContent?: boolean
   permission?: State["permission"]
@@ -121,7 +138,7 @@ export function applyDirectoryEvent(input: {
       next.splice(result.index, 0, info)
       const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
       input.setStore("session", reconcile(trimmed, { key: "id" }))
-      cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
+      cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
       if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
       break
     }
@@ -129,16 +146,15 @@ export function applyDirectoryEvent(input: {
       const info = (event.properties as { info: Session }).info
       const result = Binary.search(input.store.session, info.id, (s) => s.id)
       if (info.time.archived) {
+        if (!result.found) break
         if (input.store.session[result.index]!.time.archived === info.time.archived) break
-        if (result.found) {
-          input.setStore(
-            "session",
-            produce((draft) => {
-              draft.splice(result.index, 1)
-            }),
-          )
-        }
-        cleanupSessionCaches(input.setStore, info.id)
+        input.setStore(
+          "session",
+          produce((draft) => {
+            draft.splice(result.index, 1)
+          }),
+        )
+        cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
         if (info.parentID) break
         input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
         break
@@ -151,12 +167,15 @@ export function applyDirectoryEvent(input: {
       next.splice(result.index, 0, info)
       const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
       input.setStore("session", reconcile(trimmed, { key: "id" }))
-      cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
+      cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
       break
     }
     case "session.deleted": {
-      const info = (event.properties as { info: Session }).info
-      const result = Binary.search(input.store.session, info.id, (s) => s.id)
+      const properties = event.properties as { sessionID?: string; info?: Session }
+      const sessionID = properties.info?.id ?? properties.sessionID
+      if (!sessionID) break
+      const result = Binary.search(input.store.session, sessionID, (s) => s.id)
+      const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
       if (result.found) {
         input.setStore(
           "session",
@@ -165,14 +184,83 @@ export function applyDirectoryEvent(input: {
           }),
         )
       }
-      cleanupSessionCaches(input.setStore, info.id)
-      if (info.parentID) break
+      cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
+      if (info?.parentID) break
       input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
       break
     }
+    case "session.renamed": {
+      const properties = event.properties as { sessionID: string; title: string }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      input.setStore("session", result.index, (session) => ({
+        ...session,
+        title: properties.title,
+        time: { ...session.time, updated: Date.now() },
+      }))
+      break
+    }
+    case "session.usage.updated": {
+      const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      input.setStore("session", result.index, (session) => ({
+        ...session,
+        cost: properties.cost,
+        tokens: properties.tokens,
+      }))
+      break
+    }
+    // case "session.archived": {
+    //   const properties = event.properties as { sessionID: string }
+    //   const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+    //   if (!result.found) break
+    //   const info = input.store.session[result.index]
+    //   input.setStore(
+    //     "session",
+    //     produce((draft) => void draft.splice(result.index, 1)),
+    //   )
+    //   cleanupSessionCaches(input.setStore, properties.sessionID)
+    //   if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+    //   break
+    // }
+    case "session.moved": {
+      const properties = event.properties as {
+        sessionID: string
+        location: { directory: string; workspaceID?: string }
+        projectID?: string
+        subpath?: string
+      }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      if (properties.location.directory === input.directory) {
+        input.setStore("session", result.index, (session) => ({
+          ...session,
+          projectID: properties.projectID ?? session.projectID,
+          workspaceID: properties.location.workspaceID,
+          directory: properties.location.directory,
+          path: properties.subpath,
+          time: { ...session.time, updated: Date.now() },
+        }))
+        break
+      }
+      const info = input.store.session[result.index]
+      input.setStore(
+        "session",
+        produce((draft) => void draft.splice(result.index, 1)),
+      )
+      if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+      break
+    }
     case "session.diff": {
-      const props = event.properties as { sessionID: string; diff: SessionDiff[] }
-      input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
+      const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
+      input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
+      break
+    }
+    case "todo.updated": {
+      const props = event.properties as { sessionID: string; todos: Todo[] }
+      input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+      input.setSessionTodo?.(props.sessionID, props.todos)
       break
     }
     case "session.status": {

+ 154 - 0
packages/app/src/context/global-sync/home-session-index.test.ts

@@ -0,0 +1,154 @@
+import { describe, expect, test } from "bun:test"
+import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
+import {
+  applyHomeSessionEvent,
+  appendHomeSessionEvent,
+  HOME_V2_SESSION_PAGE_LIMIT,
+  loadHomeSessionIndex,
+  homeSessionIndexSessions,
+  homeSessionIndexRefresh,
+  parseHomeSessionIndex,
+  retainHomeSessions,
+} from "./home-session-index"
+
+const session = (input: {
+  id: string
+  directory?: string
+  parentID?: string
+  archived?: number
+  updated?: number
+}) => ({
+  id: input.id,
+  parentID: input.parentID,
+  projectID: "project",
+  cost: 0,
+  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+  time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
+  title: input.id,
+  location: { directory: input.directory ?? "/project" },
+})
+
+describe("Home V2 session index", () => {
+  test("loads the Home index with one global V2 request", async () => {
+    const calls: unknown[] = []
+    const result = await loadHomeSessionIndex(async (input) => {
+      calls.push(input)
+      return { data: { data: [session({ id: "root" })], cursor: {} } }
+    })
+
+    expect(result.sessions).toHaveLength(1)
+    expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
+  })
+
+  test("loads subsequent pages until the session index is complete", async () => {
+    const calls: unknown[] = []
+    const controller = new AbortController()
+    const result = await loadHomeSessionIndex(
+      async (input, options) => {
+        calls.push({ input, signal: options.signal })
+        if (!("cursor" in input)) {
+          return {
+            data: {
+              data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
+                session({ id: `page-1-${index}` }),
+              ),
+              cursor: { next: "next-page" },
+            },
+          }
+        }
+        return { data: { data: [session({ id: "page-2" })], cursor: {} } }
+      },
+      0,
+      controller.signal,
+    )
+
+    expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
+    expect(calls).toEqual([
+      { input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
+      {
+        input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
+        signal: controller.signal,
+      },
+    ])
+  })
+
+  test("maps visible roots to Home session summaries", () => {
+    const activeNull = {
+      ...session({ id: "active-null", updated: 20 }),
+      time: { created: 1, updated: 20, archived: null },
+    } as unknown as SessionV2Info
+    const result = parseHomeSessionIndex([
+      session({ id: "root", updated: 30 }),
+      activeNull,
+      session({ id: "child", parentID: "root", updated: 40 }),
+      session({ id: "archived", archived: 50, updated: 50 }),
+    ])
+
+    expect(result).toEqual([
+      expect.objectContaining({
+        id: "root",
+        slug: "root",
+        version: "",
+        directory: "/project",
+        projectID: "project",
+        title: "root",
+        time: { created: 1, updated: 30 },
+      }),
+      expect.objectContaining({
+        id: "active-null",
+        time: { created: 1, updated: 20, archived: null },
+      }),
+    ])
+  })
+
+  test("preserves the per-directory Home retention limit", () => {
+    const now = 10 * 60 * 60 * 1000
+    const sessions = Array.from({ length: 80 }, (_, index) => ({
+      ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
+      directory: index % 2 === 0 ? "/one" : "/two",
+    }))
+
+    const retained = retainHomeSessions(sessions, 10, now)
+    expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
+    expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
+  })
+
+  test("replays session events over the loaded index", () => {
+    const initial = parseHomeSessionIndex([session({ id: "old" })])
+    const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
+
+    const afterCreate = applyHomeSessionEvent(initial, {
+      type: "session.created",
+      properties: { sessionID: created.id, info: created },
+    })
+    expect(
+      applyHomeSessionEvent(afterCreate, {
+        type: "session.deleted",
+        properties: { sessionID: initial[0]!.id, info: initial[0]! },
+      }),
+    ).toEqual([created])
+  })
+
+  test("applies only events newer than the index baseline", () => {
+    const initial = parseHomeSessionIndex([session({ id: "old" })])
+    const stale = { ...initial[0], title: "stale" }
+    const current = { ...initial[0], title: "current" }
+    const first = appendHomeSessionEvent(undefined, {
+      type: "session.updated",
+      properties: { sessionID: stale.id, info: stale },
+    })
+    const events = appendHomeSessionEvent(first, {
+      type: "session.updated",
+      properties: { sessionID: current.id, info: current },
+    })
+
+    expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
+  })
+
+  test("refetches after reconnect, disposal, and session moves", () => {
+    expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
+    expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
+    expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
+    expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
+  })
+})

+ 174 - 0
packages/app/src/context/global-sync/home-session-index.ts

@@ -0,0 +1,174 @@
+import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
+import type { QueryClient } from "@tanstack/solid-query"
+import { trimSessions } from "./session-trim"
+import { pathKey } from "@/utils/path-key"
+
+export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
+
+export type HomeSessionEvent = {
+  type: "session.created" | "session.updated" | "session.deleted"
+  properties: { sessionID: string; info: Session }
+}
+export type HomeSessionEvents = {
+  sequence: number
+  entries: Array<{ sequence: number; event: HomeSessionEvent }>
+}
+export type HomeSessionIndex = {
+  sessions: Session[]
+  eventSequence: number
+}
+
+export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
+export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
+
+type HomeSessionPage = { data?: V2SessionListResponse }
+
+export async function loadHomeSessionIndex(
+  list: (
+    input: { limit: number; order: "desc"; cursor?: string },
+    options: { signal?: AbortSignal },
+  ) => Promise<HomeSessionPage>,
+  eventSequence = 0,
+  signal?: AbortSignal,
+) {
+  const data: SessionV2Info[] = []
+  let cursor: string | undefined
+
+  for (;;) {
+    const response = await list(
+      {
+        limit: HOME_V2_SESSION_PAGE_LIMIT,
+        order: "desc",
+        ...(cursor ? { cursor } : {}),
+      },
+      { signal },
+    )
+    const page = response.data!
+    data.push(...page.data)
+    if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
+      return { sessions: parseHomeSessionIndex(data), eventSequence }
+    cursor = page.cursor.next
+  }
+}
+
+export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
+  const sequence = (current?.sequence ?? 0) + 1
+  return {
+    sequence,
+    entries: [...(current?.entries ?? []), { sequence, event }],
+  }
+}
+
+export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
+  return {
+    sequence: current?.sequence ?? sequence,
+    entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
+  }
+}
+
+export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
+  if (!index) return []
+  return (events?.entries ?? [])
+    .filter((entry) => entry.sequence > index.eventSequence)
+    .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
+}
+
+export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
+  if (event === "server.connected") return { connected: true, refetch: connected }
+  return {
+    connected,
+    refetch: event === "global.disposed" || event === "session.next.moved",
+  }
+}
+
+export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
+  const indexKey = homeSessionIndexKey(server)
+  const eventsKey = homeSessionEventsKey(server)
+  let connected = false
+
+  return {
+    indexKey,
+    eventsKey,
+    eventSequence() {
+      return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
+    },
+    complete(sequence: number) {
+      // Keep events received after the fetch began so its response cannot overwrite them.
+      queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
+    },
+    sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
+      return homeSessionIndexSessions(index, events)
+    },
+    apply(event: HomeSessionEvent) {
+      if (!queryClient.getQueryState(indexKey)) return
+      const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
+      if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
+        queryClient.setQueryData(eventsKey, next)
+        return
+      }
+
+      const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
+      if (index) {
+        queryClient.setQueryData<HomeSessionIndex>(indexKey, {
+          sessions: homeSessionIndexSessions(index, next),
+          eventSequence: next.sequence,
+        })
+      }
+      queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
+    },
+    refresh(event: Event["type"]) {
+      const result = homeSessionIndexRefresh(event, connected)
+      connected = result.connected
+      if (!result.refetch) return
+      void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
+    },
+  }
+}
+
+// TODO(v2): This deliberately dumb full-table scan is necessary because the
+// current V2 API orders by creation time and cannot filter roots, archives, or
+// multiple directories. A bounded page could omit an old session updated today.
+// Once released, use client.v2.project.list() and client.v2.session.list({
+// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
+export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
+  return sessions.flatMap((item) => {
+    if (item.parentID || typeof item.time.archived === "number") return []
+    return [toLegacySummary(item)]
+  })
+}
+
+export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
+  const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
+  return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
+}
+
+export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
+  const info = event.properties.info
+  const index = sessions.findIndex((session) => session.id === info.id)
+  if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
+    if (index === -1) return sessions
+    return sessions.toSpliced(index, 1)
+  }
+  if (event.type !== "session.created" && event.type !== "session.updated") return sessions
+  if (index === -1) return [...sessions, info]
+  return sessions.with(index, info)
+}
+
+function toLegacySummary(session: SessionV2Info): Session {
+  return {
+    id: session.id,
+    slug: session.id,
+    projectID: session.projectID,
+    workspaceID: session.location.workspaceID,
+    directory: session.location.directory,
+    path: session.subpath,
+    parentID: session.parentID,
+    cost: session.cost,
+    tokens: session.tokens,
+    title: session.title,
+    agent: session.agent,
+    model: session.model,
+    version: "",
+    time: session.time,
+  }
+}

+ 20 - 0
packages/app/src/context/global-sync/mcp.test.ts

@@ -31,4 +31,24 @@ describe("toggleMcp", () => {
     await toggleMcp(input("disabled"))
     expect(calls).toEqual(["connect", "refresh"])
   })
+
+  test("does not toggle a server while its connection is pending", async () => {
+    const calls: string[] = []
+    await toggleMcp({
+      status: "pending",
+      connect: async () => {
+        calls.push("connect")
+      },
+      disconnect: async () => {
+        calls.push("disconnect")
+      },
+      authenticate: async () => {
+        calls.push("authenticate")
+      },
+      refresh: async () => {
+        calls.push("refresh")
+      },
+    })
+    expect(calls).toEqual([])
+  })
 })

+ 3 - 2
packages/app/src/context/global-sync/mcp.ts

@@ -1,12 +1,13 @@
-import type { McpStatus } from "@opencode-ai/sdk/v2/client"
+import type { McpServer } from "@opencode-ai/client/promise"
 
 export async function toggleMcp(input: {
-  status: McpStatus["status"]
+  status: McpServer["status"]["status"]
   connect: () => Promise<void>
   disconnect: () => Promise<void>
   authenticate: () => Promise<void>
   refresh: () => Promise<void>
 }) {
+  if (input.status === "pending") return
   await {
     connected: input.disconnect,
     needs_auth: input.authenticate,

+ 13 - 10
packages/app/src/context/global-sync/session-cache.test.ts

@@ -1,12 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-} from "@opencode-ai/sdk/v2/client"
-import type { SessionDiff } from "@/utils/diffs"
+import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
 
 const msg = (id: string, sessionID: string) =>
@@ -32,8 +26,10 @@ describe("app session cache", () => {
   test("dropSessionCaches clears orphaned parts without message rows", () => {
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, SessionDiff[] | undefined>
+      session_diff: Record<string, FileDiffInfo[] | undefined>
+      todo: Record<string, Todo[] | undefined>
       message: Record<string, Message[] | undefined>
+      session_message: Record<string, never[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
       question: Record<string, QuestionRequest[] | undefined>
@@ -41,7 +37,9 @@ describe("app session cache", () => {
     } = {
       session_status: { ses_1: { type: "busy" } as SessionStatus },
       session_diff: { ses_1: [] },
+      todo: { ses_1: [] as Todo[] },
       message: {},
+      session_message: {},
       part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
       permission: { ses_1: [] as PermissionRequest[] },
       question: { ses_1: [] as QuestionRequest[] },
@@ -53,6 +51,7 @@ describe("app session cache", () => {
     expect(store.message.ses_1).toBeUndefined()
     expect(store.part.msg_1).toBeUndefined()
     expect(store.part_text_accum_delta.prt_1).toBeUndefined()
+    expect(store.todo.ses_1).toBeUndefined()
     expect(store.session_diff.ses_1).toBeUndefined()
     expect(store.session_status.ses_1).toBeUndefined()
     expect(store.permission.ses_1).toBeUndefined()
@@ -63,8 +62,10 @@ describe("app session cache", () => {
     const m = msg("msg_1", "ses_1")
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, SessionDiff[] | undefined>
+      session_diff: Record<string, FileDiffInfo[] | undefined>
+      todo: Record<string, Todo[] | undefined>
       message: Record<string, Message[] | undefined>
+      session_message: Record<string, never[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
       question: Record<string, QuestionRequest[] | undefined>
@@ -72,7 +73,9 @@ describe("app session cache", () => {
     } = {
       session_status: {},
       session_diff: {},
+      todo: {},
       message: { ses_1: [m] },
+      session_message: {},
       part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
       permission: {},
       question: {},

+ 8 - 9
packages/app/src/context/global-sync/session-cache.ts

@@ -1,18 +1,15 @@
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-} from "@opencode-ai/sdk/v2/client"
-import type { SessionDiff } from "@/utils/diffs"
+import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
 
 export const SESSION_CACHE_LIMIT = 40
 
 type SessionCache = {
   session_status: Record<string, SessionStatus | undefined>
-  session_diff: Record<string, SessionDiff[] | undefined>
+  session_diff: Record<string, FileDiffInfo[] | undefined>
+  todo: Record<string, Todo[] | undefined>
   message: Record<string, Message[] | undefined>
+  session_message: Record<string, SessionMessageInfo[] | undefined>
   part: Record<string, Part[] | undefined>
   permission: Record<string, PermissionRequest[] | undefined>
   question: Record<string, QuestionRequest[] | undefined>
@@ -34,6 +31,8 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
 
   for (const sessionID of stale) {
     delete store.message[sessionID]
+    delete store.todo[sessionID]
+    delete store.session_message[sessionID]
     delete store.session_diff[sessionID]
     delete store.session_status[sessionID]
     delete store.permission[sessionID]

+ 22 - 14
packages/app/src/context/global-sync/session-load.ts

@@ -1,20 +1,28 @@
-import type { RootLoadArgs } from "./types"
+import type { SessionApi } from "@opencode-ai/client/promise"
+import { normalizeSessionInfo } from "@/utils/session"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
 
-export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
+  const result = await input.api.list({
+    directory: input.directory,
+    parentID: null,
+    limit: input.limit,
+    order: "desc",
+  })
+  return {
+    data: result.data.map(normalizeSessionInfo),
+    limit: input.limit,
+    limited: true,
+  } as const
+}
+
+export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
   try {
-    const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
-    return {
-      data: result.data,
-      limit: input.limit,
-      limited: true,
-    } as const
+    const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
+    return { data: result.data, limit: input.limit, limited: true } as const
   } catch {
-    const result = await input.list({ directory: input.directory, roots: true })
-    return {
-      data: result.data,
-      limit: input.limit,
-      limited: false,
-    } as const
+    const result = await input.client.session.list({ directory: input.directory, roots: true })
+    return { data: result.data, limit: input.limit, limited: false } as const
   }
 }
 

+ 12 - 19
packages/app/src/context/global-sync/types.ts

@@ -1,10 +1,7 @@
 import type {
   Agent,
-  Command,
   Config,
   LspStatus,
-  McpResource,
-  McpStatus,
   Message,
   Part,
   Path,
@@ -13,10 +10,12 @@ import type {
   ReferenceInfo,
   Session,
   SessionStatus,
+  Todo,
   VcsInfo,
 } from "@opencode-ai/sdk/v2/client"
-import type { SessionDiff } from "@/utils/diffs"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
 import type { Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
 
@@ -34,7 +33,7 @@ export type ProjectMeta = {
 export type State = {
   status: "loading" | "partial" | "complete"
   agent: Agent[]
-  command: Command[]
+  command: CommandInfo[]
   reference: ReferenceInfo[]
   project: string
   projectMeta: ProjectMeta | undefined
@@ -50,7 +49,10 @@ export type State = {
   }
   session_working(id: string): boolean
   session_diff: {
-    [sessionID: string]: SessionDiff[]
+    [sessionID: string]: FileDiffInfo[]
+  }
+  todo: {
+    [sessionID: string]: Todo[]
   }
   permission: {
     [sessionID: string]: PermissionRequest[]
@@ -60,7 +62,7 @@ export type State = {
   }
   mcp_ready: boolean
   mcp: {
-    [name: string]: McpStatus
+    [name: string]: McpServer["status"]
   }
   mcp_resource: {
     [key: string]: McpResource
@@ -72,6 +74,9 @@ export type State = {
   message: {
     [sessionID: string]: Message[]
   }
+  session_message: {
+    [sessionID: string]: SessionMessageInfo[]
+  }
   part: {
     [messageID: string]: Part[]
   }
@@ -124,18 +129,6 @@ export type DisposeCheck = {
   loadingSessions: boolean
 }
 
-export type RootLoadArgs = {
-  directory: string
-  limit: number
-  list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
-}
-
-export type RootLoadResult = {
-  data?: Session[]
-  limit: number
-  limited: boolean
-}
-
 export const MAX_DIR_STORES = 30
 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000

+ 104 - 23
packages/app/src/context/global-sync/utils.test.ts

@@ -1,36 +1,117 @@
 import { describe, expect, test } from "bun:test"
-import type { Agent } from "@opencode-ai/sdk/v2/client"
-import { directoryKey, normalizeAgentList } from "./utils"
-
-const agent = (name = "build") =>
-  ({
-    name,
-    mode: "primary",
-    permission: {},
-    options: {},
-  }) as Agent
+import type {
+  AgentListOutput,
+  ModelDefaultOutput,
+  ModelListOutput,
+  ProviderListOutput,
+} from "@opencode-ai/client/promise"
+import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
 
 describe("normalizeAgentList", () => {
-  test("keeps array payloads", () => {
-    expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
-  })
+  test("adapts current agents to the app agent shape", () => {
+    const result = normalizeAgentList([
+      {
+        id: "build",
+        name: "Build",
+        mode: "primary",
+        hidden: false,
+        color: "primary",
+        model: { id: "gpt-5", providerID: "openai", variant: "high" },
+        request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
+        system: "Build software",
+        permissions: [{ action: "read", resource: "*", effect: "allow" }],
+      },
+    ] as AgentListOutput["data"])
 
-  test("wraps a single agent payload", () => {
-    expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
+    expect(result).toEqual([
+      {
+        name: "build",
+        description: undefined,
+        mode: "primary",
+        hidden: false,
+        temperature: 0.2,
+        topP: 0.9,
+        color: "primary",
+        permission: [{ permission: "read", pattern: "*", action: "allow" }],
+        model: { providerID: "openai", modelID: "gpt-5" },
+        variant: "high",
+        prompt: "Build software",
+        options: { temperature: 0.2, topP: 0.9 },
+        steps: undefined,
+      },
+    ])
   })
+})
 
-  test("extracts agents from keyed objects", () => {
+describe("normalizePermissionRequest", () => {
+  test("adapts the current permission request to app state", () => {
     expect(
-      normalizeAgentList({
-        build: agent("build"),
-        docs: agent("docs"),
+      normalizePermissionRequest({
+        id: "permission-1",
+        sessionID: "session-1",
+        action: "read",
+        resources: ["README.md"],
+        save: ["*.md"],
+        metadata: { path: "README.md" },
+        source: { type: "tool", messageID: "message-1", callID: "call-1" },
       }),
-    ).toEqual([agent("build"), agent("docs")])
+    ).toEqual({
+      id: "permission-1",
+      sessionID: "session-1",
+      permission: "read",
+      patterns: ["README.md"],
+      always: ["*.md"],
+      metadata: { path: "README.md" },
+      tool: { messageID: "message-1", callID: "call-1" },
+    })
   })
+})
+
+describe("normalizeProviderList", () => {
+  test("groups current models into the app provider catalog", () => {
+    const result = normalizeProviderList(
+      [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
+      [
+        {
+          id: "gpt-5",
+          modelID: "gpt-5",
+          providerID: "openai",
+          name: "GPT-5",
+          capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
+          variants: [{ id: "high" }],
+          time: { released: 1 },
+          cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
+          status: "active",
+          enabled: true,
+          limit: { context: 128_000, output: 8_192 },
+        },
+        {
+          id: "gpt-old",
+          modelID: "gpt-old",
+          providerID: "openai",
+          name: "GPT Old",
+          capabilities: { tools: false, input: ["text"], output: ["text"] },
+          variants: [],
+          time: { released: 0 },
+          cost: [],
+          status: "deprecated",
+          enabled: true,
+          limit: { context: 1, output: 1 },
+        },
+      ] as ModelListOutput["data"],
+      { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
+    )
 
-  test("drops invalid payloads", () => {
-    expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
-    expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
+    expect(result.connected).toEqual(["openai"])
+    expect(result.default).toEqual({ openai: "gpt-5" })
+    expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
+    expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
+      id: "gpt-5",
+      providerID: "openai",
+      capabilities: { toolcall: true, attachment: true },
+      cost: { input: 1, output: 2 },
+      variants: { high: {} },
+    })
   })
 })
 

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