瀏覽代碼

Merge branch 'dev' of github.com:anomalyco/opencode into dev

Frank 3 周之前
父節點
當前提交
553b42fcc7
共有 100 個文件被更改,包括 6419 次插入2979 次删除
  1. 20 5
      bun.lock
  2. 4 4
      nix/hashes.json
  3. 1 0
      package.json
  4. 220 0
      packages/app/V1_API_MIGRATION.md
  5. 2 0
      packages/app/e2e/performance/timeline-stability/fixture.ts
  6. 27 6
      packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts
  7. 24 6
      packages/app/e2e/regression/cross-server-tab-close.spec.ts
  8. 37 5
      packages/app/e2e/regression/remote-session-settings.spec.ts
  9. 29 7
      packages/app/e2e/regression/remote-tab-busy.spec.ts
  10. 3 2
      packages/app/e2e/regression/review-line-comment.spec.ts
  11. 1 1
      packages/app/e2e/regression/review-open-file.spec.ts
  12. 10 7
      packages/app/e2e/regression/review-state-persistence.spec.ts
  13. 67 22
      packages/app/e2e/regression/review-terminal-stacked.spec.ts
  14. 2 2
      packages/app/e2e/regression/session-list-path-loading.spec.ts
  15. 8 4
      packages/app/e2e/regression/session-request-docks.spec.ts
  16. 17 0
      packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
  17. 3 3
      packages/app/e2e/regression/session-timeline-transport.spec.ts
  18. 4 0
      packages/app/e2e/regression/session-todo-dock-navigation.spec.ts
  19. 7 4
      packages/app/e2e/regression/subagent-child-navigation.spec.ts
  20. 23 4
      packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
  21. 36 18
      packages/app/e2e/regression/terminal-composer-focus.spec.ts
  22. 42 5
      packages/app/e2e/regression/terminal-hidden.spec.ts
  23. 29 9
      packages/app/e2e/regression/terminal-tab-switch.spec.ts
  24. 272 12
      packages/app/e2e/utils/mock-server.ts
  25. 37 9
      packages/app/e2e/utils/sse-transport.ts
  26. 1 0
      packages/app/package.json
  27. 26 1
      packages/app/src/app.tsx
  28. 8 6
      packages/app/src/components/command-palette.ts
  29. 1 2
      packages/app/src/components/dialog-command-palette-v2.tsx
  30. 88 109
      packages/app/src/components/dialog-connect-provider.tsx
  31. 1 0
      packages/app/src/components/dialog-custom-provider.tsx
  32. 3 7
      packages/app/src/components/dialog-fork.tsx
  33. 24 14
      packages/app/src/components/dialog-select-directory-v2.tsx
  34. 1 4
      packages/app/src/components/dialog-select-directory.tsx
  35. 2 2
      packages/app/src/components/dialog-select-mcp.tsx
  36. 19 1
      packages/app/src/components/dialog-select-server.tsx
  37. 27 4
      packages/app/src/components/directory-picker-domain.test.ts
  38. 13 10
      packages/app/src/components/directory-picker-domain.ts
  39. 5 2
      packages/app/src/components/edit-project.ts
  40. 7 6
      packages/app/src/components/prompt-input-v2.tsx
  41. 1 1
      packages/app/src/components/prompt-input.tsx
  42. 124 19
      packages/app/src/components/prompt-input/submit.test.ts
  43. 66 33
      packages/app/src/components/prompt-input/submit.ts
  44. 54 17
      packages/app/src/components/server/server-row-menu.tsx
  45. 7 5
      packages/app/src/components/settings-general.tsx
  46. 30 25
      packages/app/src/components/settings-providers.tsx
  47. 7 5
      packages/app/src/components/settings-v2/general.tsx
  48. 33 26
      packages/app/src/components/settings-v2/providers.tsx
  49. 35 24
      packages/app/src/components/status-popover-body.tsx
  50. 1 1
      packages/app/src/components/status-popover-indicator.test.ts
  51. 4 3
      packages/app/src/components/status-popover-indicator.ts
  52. 62 23
      packages/app/src/components/terminal.tsx
  53. 1 2
      packages/app/src/components/titlebar-tab-nav.tsx
  54. 3 2
      packages/app/src/components/titlebar.tsx
  55. 6 5
      packages/app/src/context/directory-sync.ts
  56. 11 3
      packages/app/src/context/file.tsx
  57. 124 69
      packages/app/src/context/global-sync/bootstrap.test.ts
  58. 209 54
      packages/app/src/context/global-sync/bootstrap.ts
  59. 1 0
      packages/app/src/context/global-sync/child-store.ts
  60. 7 3
      packages/app/src/context/global-sync/event-reducer.test.ts
  61. 73 7
      packages/app/src/context/global-sync/event-reducer.ts
  62. 20 0
      packages/app/src/context/global-sync/mcp.test.ts
  63. 3 2
      packages/app/src/context/global-sync/mcp.ts
  64. 8 11
      packages/app/src/context/global-sync/session-cache.test.ts
  65. 6 10
      packages/app/src/context/global-sync/session-cache.ts
  66. 22 14
      packages/app/src/context/global-sync/session-load.ts
  67. 8 19
      packages/app/src/context/global-sync/types.ts
  68. 104 23
      packages/app/src/context/global-sync/utils.test.ts
  69. 146 26
      packages/app/src/context/global-sync/utils.ts
  70. 8 2
      packages/app/src/context/layout.tsx
  71. 29 15
      packages/app/src/context/permission.tsx
  72. 37 1
      packages/app/src/context/server-sdk.test.ts
  73. 156 52
      packages/app/src/context/server-sdk.tsx
  74. 156 0
      packages/app/src/context/server-session-v2-reducer.test.ts
  75. 502 0
      packages/app/src/context/server-session-v2-reducer.ts
  76. 162 0
      packages/app/src/context/server-session.test.ts
  77. 267 38
      packages/app/src/context/server-session.ts
  78. 139 30
      packages/app/src/context/server-sync.test.ts
  79. 232 39
      packages/app/src/context/server-sync.tsx
  80. 7 0
      packages/app/src/context/settings.test.ts
  81. 11 1
      packages/app/src/context/settings.tsx
  82. 63 38
      packages/app/src/context/terminal.tsx
  83. 2 2
      packages/app/src/pages/home-session-archive.test.ts
  84. 2 12
      packages/app/src/pages/home-session-archive.ts
  85. 35 1911
      packages/app/src/pages/home.tsx
  86. 108 0
      packages/app/src/pages/home/home-controller.ts
  87. 128 0
      packages/app/src/pages/home/home-projects-controller.tsx
  88. 615 0
      packages/app/src/pages/home/home-projects-view.tsx
  89. 40 0
      packages/app/src/pages/home/home-projects.tsx
  90. 145 0
      packages/app/src/pages/home/home-scroll-controller.ts
  91. 114 0
      packages/app/src/pages/home/home-session-search-controller.ts
  92. 314 0
      packages/app/src/pages/home/home-sessions-controller.tsx
  93. 550 0
      packages/app/src/pages/home/home-sessions-view.tsx
  94. 48 0
      packages/app/src/pages/home/home-sessions.tsx
  95. 142 0
      packages/app/src/pages/home/legacy-home.tsx
  96. 30 33
      packages/app/src/pages/layout.tsx
  97. 7 5
      packages/app/src/pages/layout/project-avatar-state.ts
  98. 20 2
      packages/app/src/pages/layout/session-tab-avatar.tsx
  99. 21 62
      packages/app/src/pages/session.tsx
  100. 2 1
      packages/app/src/pages/session/composer/session-composer-controls.ts

+ 20 - 5
bun.lock

@@ -37,6 +37,7 @@
         "@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/core": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -302,7 +303,7 @@
         "@ai-sdk/google": "3.0.73",
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/groq": "3.0.31",
-        "@ai-sdk/mistral": "3.0.27",
+        "@ai-sdk/mistral": "3.0.51",
         "@ai-sdk/openai": "3.0.84",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/perplexity": "3.0.26",
@@ -577,7 +578,7 @@
         "@ai-sdk/google": "3.0.73",
         "@ai-sdk/google-vertex": "4.0.128",
         "@ai-sdk/groq": "3.0.31",
-        "@ai-sdk/mistral": "3.0.27",
+        "@ai-sdk/mistral": "3.0.51",
         "@ai-sdk/openai": "3.0.84",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/perplexity": "3.0.26",
@@ -798,6 +799,7 @@
       "version": "1.18.4",
       "dependencies": {
         "@kobalte/core": "catalog:",
+        "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -1074,8 +1076,9 @@
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
-    "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+    "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+    "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
     "pacote@21.5.0": "patches/pacote@21.5.0.patch",
@@ -1199,7 +1202,7 @@
 
     "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
 
-    "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
+    "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
 
     "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
 
@@ -5699,7 +5702,9 @@
 
     "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
-    "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+    "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+
+    "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
 
     "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
@@ -6025,6 +6030,8 @@
 
     "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
 
+    "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
+
     "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
 
     "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6043,6 +6050,8 @@
 
     "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
 
+    "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
+
     "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
 
     "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
@@ -6173,6 +6182,8 @@
 
     "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
 
+    "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
+
     "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
 
     "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -6989,6 +7000,8 @@
 
     "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
 
+    "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+
     "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
     "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -7415,6 +7428,8 @@
 
     "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
+    "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
 
     "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
-    "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
-    "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
-    "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
+    "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
+    "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
+    "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
+    "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
   }
 }

+ 1 - 0
package.json

@@ -149,6 +149,7 @@
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
+    "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "pacote@21.5.0": "patches/pacote@21.5.0.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",

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

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

@@ -97,6 +97,7 @@ export async function setupTimeline(
     locale?: string
     deviceScaleFactor?: number
     seedHistory?: boolean
+    protocol?: "v1" | "v2"
   } = {},
 ) {
   const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
     retry: input.eventRetry ?? 20,
   })
   await mockOpenCodeServer(page, {
+    protocol: input.protocol,
     directory,
     project: project(),
     provider: provider(),

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

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

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

+ 29 - 7
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,11 +58,15 @@ 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, [])
@@ -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")

+ 10 - 7
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: "v2",
     directory,
     project: {
       id: projectID,
@@ -92,18 +93,20 @@ async function setup(page: Page) {
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
+      body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }),
     }),
   )
   await page.route("**/vcs/diff**", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(
-        new URL(route.request().url()).searchParams.get("mode") === "branch"
-          ? [diff("src/alpha.ts"), diff("src/beta.ts")]
-          : [diff("src/alpha.ts"), diff("src/gamma.ts")],
-      ),
+      body: JSON.stringify({
+        location: { directory },
+        data:
+          new URL(route.request().url()).searchParams.get("mode") === "branch"
+            ? [diff("src/alpha.ts"), diff("src/beta.ts")]
+            : [diff("src/alpha.ts"), diff("src/gamma.ts")],
+      }),
     }),
   )
   await page.addInitScript(

+ 67 - 22
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: "v2",
     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,39 +66,80 @@ 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({
+        location: { directory },
+        data: { branch: "review-pane-performance", defaultBranch: "dev" },
+      }),
     }),
   )
-  await page.route("**/vcs/diff**", (route) => {
+  await page.route("**/api/vcs/diff**", (route) => {
     const url = new URL(route.request().url())
-    const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
+    const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
     const detail = scope?.endsWith("/src/branch/d00027")
     if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(
-        url.searchParams.get("mode") === "branch"
-          ? detail
-            ? branchDiffs
-                .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
-                .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
-            : branchDiffs
-          : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
-      ),
+      body: JSON.stringify({
+        location: { directory: scope ?? directory, project: { id: projectID, directory } },
+        data:
+          url.searchParams.get("mode") === "branch"
+            ? detail
+              ? branchDiffs
+                  .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
+                  .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
+              : branchDiffs
+            : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
+      }),
     })
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/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("**/api/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("**/api/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.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
     localStorage.setItem(
@@ -134,8 +177,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
   const lazyDiff = page.waitForRequest((request) => {
     const url = new URL(request.url())
     return (
-      url.pathname === "/vcs/diff" &&
-      url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
+      url.pathname === "/api/vcs/diff" &&
+      url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
     )
   })
   await lastFile.click()
@@ -143,15 +186,17 @@ 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) => {
     const url = new URL(request.url())
     return (
-      url.pathname === "/vcs/diff" &&
-      url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
+      url.pathname === "/api/vcs/diff" &&
+      url.searchParams.get("location[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,

+ 17 - 0
packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts

@@ -32,6 +32,23 @@ for (const expanded of [false, true]) {
   })
 }
 
+test("shows and expands a running shell command without shimmering it", async ({ page }) => {
+  const id = "prt_shell_running_command"
+  const command = "sleep 10 && echo done"
+  await setupTimeline(page, {
+    messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
+    settings: { shellToolPartsExpanded: false },
+  })
+
+  const tool = page.locator(`[data-timeline-part-id="${id}"]`)
+  await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
+  await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
+  await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
+  await tool.locator('[data-slot="collapsible-trigger"]').click()
+  await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
+  await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
+})
+
 test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
   const reasoningID = "prt_reasoning_hidden"
   const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })

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

@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
   expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
 })
 
-test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
-  const timeline = await setupTimeline(page, { eventRetry: 10 })
+test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
+  const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
   const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
     id: "timeline-event-7",
   })
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
   const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
 
   expect(first.eventID).toBe("timeline-event-7")
-  expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
+  expect(connection.headers["last-event-id"]).toBeUndefined()
 })
 
 test("passes through non-event fetches", async ({ page }) => {

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

@@ -27,6 +27,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
   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,
@@ -56,9 +57,11 @@ test("animates todo lifecycle without replaying it across session tabs", async (
       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)
@@ -68,6 +71,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
   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()
 

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

+ 23 - 4
packages/app/e2e/regression/tab-navigate-mousedown.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 server = "http://127.0.0.1:4096"
 const sessionA = session("ses_tab_a", "Tab A session")
@@ -56,9 +57,15 @@ async function mockServer(page: Page) {
   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 (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)
@@ -66,8 +73,7 @@ async function mockServer(page: Page) {
     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 +95,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")).toBe("e2e-ticket")
   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 }
+}

+ 272 - 12
packages/app/e2e/utils/mock-server.ts

@@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
 const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
 
 export interface MockServerConfig {
+  protocol?: "v1" | "v2"
   provider: unknown
   directory: string
   project: unknown
@@ -23,7 +24,7 @@ export interface MockServerConfig {
   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) {
@@ -54,19 +55,35 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (url.port !== targetPort && url.port !== appPort) return route.fallback()
 
     const path = url.pathname
-    if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
-    if (path === "/global/health") return json(route, { healthy: true })
-    if (path === "/api/session")
-      return json(route, {
-        data: config.sessions.map((session) => v2Session(session, config.directory)),
-        cursor: {},
-      })
-    if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
+    if (path === "/global/event" || path === "/event" || path === "/api/event") {
+      const events = config.events?.()
+      return sse(
+        route,
+        path === "/api/event"
+          ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
+          : [
+              ...(path === "/global/event"
+                ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
+                : []),
+              ...(events ?? []),
+            ],
+        config.eventRetry,
+      )
+    }
+    if (path === "/global/health")
+      return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
+    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 === "/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") ?? ""))
@@ -89,10 +106,135 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
         },
         data: [],
       })
+    if (path === "/api/agent")
+      return json(route, {
+        location: location(config),
+        data: [
+          {
+            id: "build",
+            name: "Build",
+            mode: "primary",
+            hidden: false,
+            request: { settings: {}, headers: {}, body: {} },
+            permissions: [],
+          },
+        ],
+      })
+    if (path === "/api/command") return json(route, { location: location(config), data: [] })
+    if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
+    if (path === "/api/mcp/resource")
+      return json(route, { location: location(config), data: { resources: [], templates: [] } })
+    const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
+    if (integration && route.request().method() === "GET")
+      return json(route, {
+        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")
+      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 })
+    if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
+    if (path === "/api/path")
+      return json(route, {
+        state: config.directory,
+        config: config.directory,
+        worktree: config.directory,
+        directory: config.directory,
+        home: "C:/OpenCode",
+      })
+    if (path === "/api/permission/request")
+      return json(route, {
+        location: location(config),
+        data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
+          currentPermission,
+        ),
+      })
+    if (path === "/api/question/request")
+      return json(route, {
+        location: location(config),
+        data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
+      })
+    if (path === "/api/vcs")
+      return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
+    if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
+    if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
+    if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
+    if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
+      return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
+    if (path === "/api/session") {
+      const directory = url.searchParams.get("directory")
+      const parentID = url.searchParams.get("parentID")
+      const limit = Number(url.searchParams.get("limit") ?? 50)
+      const offset = Number(url.searchParams.get("cursor") ?? 0)
+      const sessions = config.sessions
+        .filter((session) => !directory || session.directory === directory)
+        .filter((session) => parentID !== "null" || session.parentID === undefined)
+        .filter((session) => {
+          const search = url.searchParams.get("search")?.toLowerCase()
+          return (
+            !search ||
+            String(session.title ?? "")
+              .toLowerCase()
+              .includes(search)
+          )
+        })
+      const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
+      const data = ordered.slice(offset, offset + limit)
+      const next = offset + limit < ordered.length ? String(offset + limit) : undefined
+      return json(route, {
+        data: data.map((session) => currentSession(session, config.directory)),
+        cursor: { next },
+      })
+    }
+    if (path === "/api/session/active") {
+      const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
+      return json(route, {
+        data: Object.fromEntries(
+          Object.entries(statuses).flatMap(([id, status]) =>
+            status.type === "idle" ? [] : [[id, { type: "running" }]],
+          ),
+        ),
+      })
+    }
+    if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    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"
+    ) {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
     if (path in staticRoutes) return json(route, staticRoutes[path])
 
+    const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
+    if (currentSessionMatch) {
+      const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
+      if (!session) return json(route, { error: "Session not found" }, undefined, 404)
+      return json(route, {
+        data: currentSession(session, config.directory),
+      })
+    }
+
     const sessionMatch = path.match(/^\/session\/([^/]+)$/)
     if (sessionMatch) {
       const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -115,6 +257,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     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$/)
+    if (currentMessagesMatch) {
+      const token = url.searchParams.get("cursor") ?? undefined
+      const before = token ? cursors.get(token) : undefined
+      if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
+      config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
+      await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
+      config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
+      const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
+      if (cursor) cursors.set(cursor, pageData.cursor!)
+      return json(route, {
+        data: pageData.items.map(currentMessage).reverse(),
+        cursor: { next: cursor },
+      })
+    }
+
     const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
     if (messagesMatch) {
       const token = url.searchParams.get("before") ?? undefined
@@ -137,12 +297,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
   })
 }
 
-function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
+function location(config: MockServerConfig) {
+  return {
+    directory: config.directory,
+    project: { id: (config.project as { id?: string }).id, directory: config.directory },
+  }
+}
+
+function currentPermission(value: unknown) {
+  const permission = value as Record<string, unknown>
+  if (permission.action) return permission
+  const tool = permission.tool as { messageID?: string; callID?: string } | undefined
+  return {
+    id: permission.id,
+    sessionID: permission.sessionID,
+    action: permission.permission,
+    resources: permission.patterns ?? [],
+    save: permission.always,
+    metadata: permission.metadata,
+    source:
+      tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
+  }
+}
+
+export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
   const time = session.time && typeof session.time === "object" ? session.time : {}
   return {
     id: session.id,
     parentID: session.parentID,
     projectID: session.projectID ?? "project",
+    agent: session.agent ?? "build",
+    model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
     cost: session.cost ?? 0,
     tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
     time: {
@@ -157,7 +342,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
       directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
       ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
     },
-    ...(typeof session.path === "string" ? { subpath: session.path } : {}),
+    subpath: session.path,
+    revert: session.revert,
+  }
+}
+
+function currentMessage(value: unknown) {
+  const item = value as {
+    info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
+    parts: Array<Record<string, unknown> & { type: string }>
+  }
+  if (item.info.role === "user") {
+    return {
+      id: item.info.id,
+      type: "user",
+      time: item.info.time,
+      text: item.parts
+        .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
+        .join("\n"),
+    }
+  }
+  return {
+    id: item.info.id,
+    type: "assistant",
+    time: item.info.time,
+    agent: item.info.agent ?? "build",
+    model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
+    cost: item.info.cost,
+    tokens: item.info.tokens,
+    error: item.info.error,
+    content: item.parts.flatMap<unknown>((part) => {
+      if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
+      if (part.type !== "tool") return []
+      const state = part.state as Record<string, unknown>
+      return [
+        {
+          type: "tool",
+          id: part.id,
+          name: part.tool,
+          time: state.time ?? { created: item.info.time.created },
+          state:
+            state.status === "pending"
+              ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
+              : state.status === "completed"
+                ? {
+                    status: "completed",
+                    input: state.input ?? {},
+                    structured: state.metadata ?? {},
+                    content: [{ type: "text", text: state.output ?? "" }],
+                  }
+                : state.status === "error"
+                  ? {
+                      status: "error",
+                      input: state.input ?? {},
+                      structured: state.metadata ?? {},
+                      content: [],
+                      error: { type: "ToolError", message: state.error ?? "Tool failed" },
+                    }
+                  : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
+        },
+      ]
+    }),
   }
 }
 
@@ -181,3 +426,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
     body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
   })
 }
+
+function currentEvent(input: unknown) {
+  if (!input || typeof input !== "object" || !("payload" in input)) return input
+  const envelope = input as { directory?: string; payload?: unknown }
+  if (!envelope.payload || typeof envelope.payload !== "object") return input
+  const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
+  if (!payload.type) return input
+  return {
+    id: payload.id ?? `evt_mock_${Date.now()}`,
+    created: Date.now(),
+    type: payload.type,
+    data: payload.properties ?? {},
+    location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
+  }
+}

+ 37 - 9
packages/app/e2e/utils/sse-transport.ts

@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
 export type SseConnectionRecord = {
   id: number
   url: string
-  path: "/global/event" | "/event"
+  path: "/global/event" | "/event" | "/api/event"
   headers: Record<string, string>
   openedAt: number
   endedAt?: number
@@ -93,6 +93,21 @@ export async function installSseTransport<T>(
           eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
           `data: ${JSON.stringify(payload)}\n\n`,
         ].join("")
+      const currentEvent = (input: unknown) => {
+        if (!input || typeof input !== "object" || !("payload" in input)) return input
+        const envelope = input as { directory?: string; payload?: unknown }
+        if (!envelope.payload || typeof envelope.payload !== "object") return input
+        const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
+        if (!payload.type) return input
+        return {
+          id: payload.id ?? `evt_mock_${Date.now()}`,
+          created: Date.now(),
+          type: payload.type,
+          data: payload.properties ?? {},
+          location:
+            envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
+        }
+      }
       const acknowledge = (
         connection: Connection,
         bytes: number,
@@ -140,15 +155,13 @@ export async function installSseTransport<T>(
           output.forEach((chunk) => connection.controller.enqueue(chunk))
           return acknowledge(connection, input.bytes.length, output.length)
         }
-        const encoded = input.deliveries.map((delivery) => ({
-          delivery,
-          bytes: encoder.encode(frame(delivery.payload, delivery.options)),
-        }))
+        const encoded = input.deliveries.map((delivery) => {
+          const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
+          return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
+        })
         encoded.forEach((item) => marker(item.delivery.options?.marker))
         if (input.burst) {
-          const bytes = encoder.encode(
-            encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
-          )
+          const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
           connection.controller.enqueue(bytes)
           return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
         }
@@ -161,7 +174,10 @@ export async function installSseTransport<T>(
       const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
         const request = new Request(input, init)
         const url = new URL(request.url)
-        if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
+        if (
+          url.origin !== server ||
+          (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
+        )
           return originalFetch(request)
 
         const id = ++nextConnectionID
@@ -177,6 +193,18 @@ export async function installSseTransport<T>(
             record.controller = controller
             connections.push(record)
             if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
+            if (url.pathname === "/api/event")
+              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",
               () => {

+ 1 - 0
packages/app/package.json

@@ -53,6 +53,7 @@
     "@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/core": "workspace:*",
     "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",

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

+ 88 - 109
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,8 @@ import {
   Switch,
 } from "solid-js"
 import { createStore, produce } from "solid-js/store"
+import { useQueryClient } from "@tanstack/solid-query"
+import { useParams } from "@solidjs/router"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
@@ -35,8 +37,11 @@ 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"
+import { pathKey } from "@/utils/path-key"
 
 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 })
@@ -228,8 +233,6 @@ function ProviderPickerV2(props: {
 }) {
   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 +269,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 +386,17 @@ function ProviderConnection(props: {
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
+  const queryClient = useQueryClient()
+  const params = useParams()
   const language = useLanguage()
   const settings = useSettings()
   const newLayout = settings.general.newLayoutDesigns
   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 +411,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 +448,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 +502,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 +512,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 +543,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 +573,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 +606,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 +707,10 @@ function ProviderConnection(props: {
   })
 
   async function complete() {
-    await serverSDK().client.global.dispose()
+    const value = directory()
+    await queryClient
+      .refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
+      .catch(() => undefined)
     dialog.close()
     showToast({
       variant: "success",
@@ -805,7 +774,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 +820,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()
     }
@@ -984,12 +950,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 +1043,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 +1157,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>

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

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

+ 24 - 14
packages/app/src/components/dialog-select-directory-v2.tsx

@@ -28,6 +28,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 +68,7 @@ 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
-        .get()
-        .then((result) => result.data)
-        .catch(() => undefined),
+    () => sdk.api.path.get().catch(() => undefined),
     { initialValue: undefined },
   )
   const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -85,18 +82,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 +120,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)

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

@@ -60,10 +60,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   const [fallbackPath] = createResource(
     () => (missingBase() ? true : undefined),
     async () => {
-      return sdk.client.path
-        .get()
-        .then((x) => x.data)
-        .catch(() => undefined)
+      return sdk.api.path.get().catch(() => undefined)
     },
     { initialValue: undefined },
   )

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

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

+ 5 - 2
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,15 @@ 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({
+        const project = await serverCtx().sdk.api.project.update({
           projectID: props.project.id,
-          directory: props.project.worktree,
           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

+ 7 - 6
packages/app/src/components/prompt-input-v2.tsx

@@ -310,7 +310,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 +327,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 +447,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",

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

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

+ 66 - 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
@@ -235,9 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return Promise.resolve()
     }
     return sdk()
-      .client.session.abort({
-        sessionID,
-      })
+      .api.session.interrupt({ sessionID })
       .catch(() => {})
   }
 
@@ -364,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"),
@@ -450,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({
@@ -473,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")),
@@ -573,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
 
     void sendFollowupDraft({
-      client,
+      api: sdk().api.session,
       sync: sync(),
       serverSync: serverSync(),
       draft,

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

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

@@ -127,11 +127,13 @@ 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
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 30 - 25
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,6 +39,7 @@ 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 providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -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="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

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

@@ -122,11 +122,13 @@ 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
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 33 - 26
packages/app/src/components/settings-v2/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"
@@ -33,6 +33,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
   const dialog = useDialog()
   const language = useLanguage()
   const serverSdk = useServerSDK()
+  const protocol = useServerProtocol()
   const serverSync = useServerSync()
   const providers = useProviders()
   const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -77,7 +78,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 +92,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 +221,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="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()}>

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

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

@@ -26,7 +26,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", () => {

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

@@ -1,11 +1,12 @@
-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 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")
   )
 }

+ 62 - 23
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,60 @@ 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" },
-            },
-          )
+        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)
           .catch((err: unknown) => {
-            if (err instanceof Error && err.message.includes("Request is not supported")) return
+            if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") {
+              throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
+            }
             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}`)
       }
 
       const retry = (err: unknown) => {
@@ -579,11 +615,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,

+ 1 - 2
packages/app/src/components/titlebar-tab-nav.tsx

@@ -120,8 +120,7 @@ export function TabNavItem(props: {
     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 })
+    await ctx.sdk.api.session.rename({ sessionID: session.id, title })
   }
 
   const closeRename = async (save: boolean) => {

+ 3 - 2
packages/app/src/components/titlebar.tsx

@@ -28,6 +28,7 @@ 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>
@@ -267,9 +268,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(() => {}),
             )
 

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

@@ -5,6 +5,7 @@ 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([
@@ -15,6 +16,7 @@ const sessionFields = new Set([
   "permission",
   "question",
   "message",
+  "session_message",
   "part",
   "part_text_accum_delta",
 ])
@@ -114,7 +116,6 @@ 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) {
@@ -123,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)
@@ -133,7 +134,7 @@ 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() } })
+        await serverSDK.api.session.archive({ sessionID, directory })
         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 []

+ 124 - 69
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -1,14 +1,39 @@
 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, ProjectApi, 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"
 
 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>({
@@ -41,6 +66,7 @@ function directoryState() {
     vcs: undefined,
     limit: 5,
     message: {},
+    session_message: {},
     part: {},
     part_text_accum_delta: {},
   })
@@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
       sdk: {
         app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
         config: { get: async () => ({ data: {} }) },
-        session: { status: async () => ({ data: {} }) },
         vcs: { get: async () => ({ data: undefined }) },
         command: {
           list: async () => {
@@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
         },
         provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
       } as unknown as OpencodeClient,
+      api,
       store,
       setStore,
       vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -98,79 +124,108 @@ describe("bootstrapDirectory", () => {
     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()
-  })
 })
 
 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)
   })
 })

+ 209 - 54
packages/app/src/context/global-sync/bootstrap.ts

@@ -9,6 +9,26 @@ import type {
   ReferenceInfo,
   Session,
 } from "@opencode-ai/sdk/v2/client"
+import type {
+  AgentListInput,
+  AgentListOutput,
+  CatalogApi,
+  CommandInfo,
+  CommandListInput,
+  CommandListOutput,
+  McpApi,
+  PathGetInput,
+  PathGetOutput,
+  PermissionApi,
+  ProjectCurrentInput,
+  ProjectCurrentOutput,
+  ProjectListOutput,
+  QuestionApi,
+  ReferenceListInput,
+  ReferenceListOutput,
+  SessionApi,
+  VcsApi,
+} 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 +36,20 @@ 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"
 
 type GlobalStore = {
   ready: boolean
@@ -88,15 +116,25 @@ 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 PathApi = {
+  readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
+}
+
+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 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
 
 export async function bootstrapGlobal(input: {
   serverSDK: OpencodeClient
+  serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
+  protocol?: Promise<ServerProtocol>
   scope: ServerScope
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
@@ -114,11 +154,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.serverAPI.path)),
     () =>
       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 +205,117 @@ 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, api: PathApi) =>
   queryOptions<Path>({
     queryKey: [scope, directory, "path"],
-    queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
+    queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
   })
 
-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 +324,18 @@ export async function bootstrapDirectory(input: {
   scope: ServerScope
   mcp: boolean
   sdk: OpencodeClient
+  api: CatalogApi & {
+    readonly agent: AgentListApi
+    readonly command: CommandListApi
+    readonly mcp: McpApi
+    readonly path: PathApi
+    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 +349,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 +369,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 +395,56 @@ 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.api.path))
+            .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
+          input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
+            const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
             input.setStore("vcs", next)
             if (next) input.vcsCache.setStore("value", next)
           }),
         ),
-      input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
-      () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
+      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),
+        ),
       () =>
         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 +468,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 +503,20 @@ 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.mcp &&
+        (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
       () =>
-        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()

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

@@ -255,6 +255,7 @@ export function createChildStoreManager(input: {
             vcs: vcsStore.value,
             limit: 5,
             message: {},
+            session_message: {},
             part: {},
             part_text_accum_delta: {},
           })

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

@@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
     vcs: undefined,
     limit: 10,
     message: {},
+    session_message: {},
     part: {},
     part_text_accum_delta: {},
     ...input,
@@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => {
 
   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) {
@@ -286,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() {},

+ 73 - 7
packages/app/src/context/global-sync/event-reducer.ts

@@ -8,9 +8,9 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
-  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
@@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: {
       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",
@@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: {
           }),
         )
       }
-      cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
-      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: SnapshotFileDiff[] }
-      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": {

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

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

@@ -1,13 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-  SnapshotFileDiff,
-  Todo,
-} from "@opencode-ai/sdk/v2/client"
+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) =>
@@ -33,9 +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, SnapshotFileDiff[] | 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>
@@ -45,6 +39,7 @@ describe("app session cache", () => {
       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[] },
@@ -67,9 +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, SnapshotFileDiff[] | 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>
@@ -79,6 +75,7 @@ describe("app session cache", () => {
       session_diff: {},
       todo: {},
       message: { ses_1: [m] },
+      session_message: {},
       part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
       permission: {},
       question: {},

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

@@ -1,20 +1,15 @@
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-  SnapshotFileDiff,
-  Todo,
-} from "@opencode-ai/sdk/v2/client"
+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, SnapshotFileDiff[] | 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>
@@ -37,6 +32,7 @@ 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
   }
 }
 

+ 8 - 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,11 +10,12 @@ import type {
   ReferenceInfo,
   Session,
   SessionStatus,
-  SnapshotFileDiff,
   Todo,
   VcsInfo,
 } from "@opencode-ai/sdk/v2/client"
+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"
 
@@ -35,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
@@ -51,7 +49,7 @@ export type State = {
   }
   session_working(id: string): boolean
   session_diff: {
-    [sessionID: string]: SnapshotFileDiff[]
+    [sessionID: string]: FileDiffInfo[]
   }
   todo: {
     [sessionID: string]: Todo[]
@@ -64,7 +62,7 @@ export type State = {
   }
   mcp_ready: boolean
   mcp: {
-    [name: string]: McpStatus
+    [name: string]: McpServer["status"]
   }
   mcp_resource: {
     [key: string]: McpResource
@@ -76,6 +74,9 @@ export type State = {
   message: {
     [sessionID: string]: Message[]
   }
+  session_message: {
+    [sessionID: string]: SessionMessageInfo[]
+  }
   part: {
     [messageID: string]: Part[]
   }
@@ -128,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: {} },
+    })
   })
 })
 

+ 146 - 26
packages/app/src/context/global-sync/utils.ts

@@ -1,39 +1,152 @@
-import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type {
+  AgentListOutput,
+  ModelDefaultOutput,
+  ModelListOutput,
+  PermissionV2Request,
+  ProviderListOutput,
+} from "@opencode-ai/client/promise"
+import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type { Project as CurrentProject } from "@opencode-ai/client/promise"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
-function isAgent(input: unknown): input is Agent {
-  if (!input || typeof input !== "object") return false
-  const item = input as { name?: unknown; mode?: unknown }
-  if (typeof item.name !== "string") return false
-  return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
+export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
+  if (input.every((agent) => !("request" in agent))) return input as Agent[]
+  return (input as AgentListOutput["data"]).map((agent) => ({
+    name: agent.id,
+    description: agent.description,
+    mode: agent.mode,
+    hidden: agent.hidden,
+    temperature:
+      typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
+    topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
+    color: agent.color,
+    permission: agent.permissions.map((rule) => ({
+      permission: rule.action,
+      pattern: rule.resource,
+      action: rule.effect,
+    })),
+    model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
+    variant: agent.model?.variant,
+    prompt: agent.system,
+    options: agent.request.settings,
+    steps: agent.steps,
+  }))
 }
 
-export function normalizeAgentList(input: unknown): Agent[] {
-  if (Array.isArray(input)) return input.filter(isAgent)
-  if (isAgent(input)) return [input]
-  if (!input || typeof input !== "object") return []
-  return Object.values(input).filter(isAgent)
+export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
+  if ("permission" in input) return input
+  return {
+    id: input.id,
+    sessionID: input.sessionID,
+    permission: input.action,
+    patterns: input.resources,
+    always: input.save ?? [],
+    metadata: input.metadata ?? {},
+    tool:
+      input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
+  }
 }
 
-export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
-  return {
-    ...input,
-    all: new Map(
-      input.all.map(
-        (provider) =>
-          [
-            provider.id,
-            {
-              ...provider,
-              models: Object.fromEntries(
-                Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
-              ),
-            },
-          ] as const,
+export function normalizeProviderList(
+  providers: ProviderListOutput["data"] | ProviderListResponse,
+  models?: ModelListOutput["data"],
+  defaultModel?: ModelDefaultOutput["data"],
+): NormalizedProviderListResponse {
+  if (!Array.isArray(providers)) {
+    return {
+      ...providers,
+      all: new Map(
+        providers.all.map((provider) => [
+          provider.id,
+          {
+            ...provider,
+            models: Object.fromEntries(
+              Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
+            ),
+          },
+        ]),
       ),
+    }
+  }
+  const all = new Map<string, Provider>()
+
+  for (const provider of providers) {
+    all.set(provider.id, {
+      id: provider.id,
+      name: provider.name,
+      source: "custom",
+      env: [],
+      options: provider.settings ?? {},
+      models: {},
+    })
+  }
+
+  for (const model of models ?? []) {
+    const provider = all.get(model.providerID)
+    if (!provider || model.status === "deprecated") continue
+    const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
+    provider.models[model.id] = {
+      id: model.id,
+      providerID: model.providerID,
+      api: {
+        id: model.modelID,
+        url: "",
+        npm: model.package ?? provider.id,
+      },
+      name: model.name,
+      family: model.family,
+      capabilities: {
+        temperature: false,
+        reasoning: false,
+        attachment: model.capabilities.input.some((item) => item !== "text"),
+        toolcall: model.capabilities.tools,
+        input: {
+          text: model.capabilities.input.includes("text"),
+          audio: model.capabilities.input.includes("audio"),
+          image: model.capabilities.input.includes("image"),
+          video: model.capabilities.input.includes("video"),
+          pdf: model.capabilities.input.includes("pdf"),
+        },
+        output: {
+          text: model.capabilities.output.includes("text"),
+          audio: model.capabilities.output.includes("audio"),
+          image: model.capabilities.output.includes("image"),
+          video: model.capabilities.output.includes("video"),
+          pdf: model.capabilities.output.includes("pdf"),
+        },
+        interleaved: false,
+      },
+      cost: {
+        input: cost?.input ?? 0,
+        output: cost?.output ?? 0,
+        cache: {
+          read: cost?.cache.read ?? 0,
+          write: cost?.cache.write ?? 0,
+        },
+      },
+      limit: model.limit,
+      status: model.status,
+      options: model.settings ?? {},
+      headers: model.headers ?? {},
+      release_date: new Date(model.time.released).toISOString().slice(0, 10),
+      variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
+    }
+  }
+
+  return {
+    all,
+    connected: providers.map((provider) => provider.id),
+    default: Object.fromEntries(
+      providers.flatMap((provider) => {
+        const model =
+          defaultModel?.providerID === provider.id
+            ? defaultModel
+            : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
+        return model ? [[provider.id, model.id]] : []
+      }),
     ),
   }
 }
@@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) {
     },
   }
 }
+
+export function normalizeProjectInfo(project: Project | CurrentProject): Project {
+  return {
+    ...project,
+    vcs: project.vcs === "git" ? "git" : undefined,
+  }
+}

+ 8 - 2
packages/app/src/context/layout.tsx

@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
 import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
 import { usePlatform } from "./platform"
 import { Project } from "@opencode-ai/sdk/v2"
+import { normalizeProjectInfo } from "./global-sync/utils"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
 import { pathKey } from "@/utils/path-key"
 import { decode64 } from "@/utils/base64"
@@ -126,7 +127,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
   }
 }
 
-const currentRoute = (pathname: string, search: string): LayoutRoute => {
+export const currentRoute = (pathname: string, search: string): LayoutRoute => {
   const parts = pathname.split("/").filter(Boolean)
   if (parts.length === 0) return { type: "home" }
 
@@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         }
 
         void serverSdk()
-          .client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
+          .api.project.update({ projectID: project.id, icon: { color } })
+          .then((result) =>
+            serverSync().set("project", (items) =>
+              items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
+            ),
+          )
           .catch(() => {
             if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
           })

+ 29 - 15
packages/app/src/context/permission.tsx

@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
 import { useSettings } from "./settings"
 import { requireServerKey } from "@/utils/session-route"
 import type { ServerScope } from "@/utils/server-scope"
+import { normalizePermissionRequest } from "./global-sync/utils"
 import {
   acceptKey,
   directoryAcceptKey,
@@ -211,6 +212,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
   )
 
   function enableConfiguredDirectory(directory: string) {
+    if (input.sdk.protocolKind() !== "v1") return
     if (meta.disposed || !ready()) return
     const [childStore] = input.sync.child(directory)
     if (childStore.config.permission !== "allow") return
@@ -243,9 +245,25 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
 
   const respond: PermissionRespondFn = (request) => {
     if (meta.disposed) return
-    input.sdk.client.permission.respond(request).catch(() => {
-      responded.delete(request.permissionID)
-    })
+    input.sdk.api.permission
+      .reply({
+        sessionID: request.sessionID,
+        requestID: request.permissionID,
+        reply: request.response,
+        location: request.directory ? { directory: request.directory } : undefined,
+      })
+      .catch(() => {
+        responded.delete(request.permissionID)
+      })
+  }
+
+  const list = async (directory: string) => {
+    if ((await input.sdk.protocol) === "v1") {
+      return (await input.sdk.client.permission.list({ directory })).data ?? []
+    }
+    return input.sdk.api.permission.request
+      .list({ location: { directory } })
+      .then((result) => result.data.map(normalizePermissionRequest))
   }
 
   function respondOnce(permission: PermissionRequest, directory?: string) {
@@ -343,14 +361,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
       }),
     )
 
-    input.sdk.client.permission
-      .list({ directory })
-      .then((x) => {
+    list(directory)
+      .then((permissions) => {
         if (meta.disposed) return
         if (!isAutoAcceptingDirectory(directory)) return
-        for (const perm of x.data ?? []) {
-          if (!perm?.id) continue
-          void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
+        for (const permission of permissions) {
+          void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
         }
       })
       .catch(() => undefined)
@@ -377,16 +393,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
       }),
     )
 
-    input.sdk.client.permission
-      .list({ directory })
-      .then((x) => {
+    list(directory)
+      .then((permissions) => {
         if (meta.disposed) return
         if (enableVersion.get(key) !== version) return
         if (!isAutoAccepting(sessionID, directory)) return
-        for (const perm of x.data ?? []) {
-          if (!perm?.id) continue
+        for (const permission of permissions) {
           void respondPending(
-            perm,
+            permission,
             directory,
             () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
           )

+ 37 - 1
packages/app/src/context/server-sdk.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
+import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
+import type { OpenCodeEvent } from "@opencode-ai/client/promise"
 import type { Event } from "@opencode-ai/sdk/v2/client"
 
 describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
   })
 })
 
+describe("adaptServerEvent", () => {
+  test("preserves V2 events while adapting permission requests for existing consumers", () => {
+    const current = {
+      id: "evt_1",
+      created: 1,
+      type: "permission.v2.asked",
+      data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
+    } as OpenCodeEvent
+
+    expect(adaptServerEvent(current)).toMatchObject({
+      type: "permission.asked",
+      properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
+      current,
+    })
+  })
+})
+
 describe("coalesceServerEvents", () => {
   const delta = (value: string, field = "text", partID = "part") => ({
     directory: "/repo",
@@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => {
     expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
   })
 
+  test("merges adjacent current text deltas", () => {
+    const current = (id: string, value: string) =>
+      adaptServerEvent({
+        id,
+        created: 1,
+        type: "session.text.delta",
+        location: { directory: "/repo" },
+        data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
+      } as OpenCodeEvent)
+    const result = coalesceServerEvents([
+      { directory: "/repo", payload: current("evt_1", "hello ") },
+      { directory: "/repo", payload: current("evt_2", "world") },
+    ])
+
+    expect(result).toHaveLength(1)
+    expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
+  })
+
   test("preserves event boundaries and distinct fields", () => {
     const status = {
       directory: "/repo",

+ 156 - 52
packages/app/src/context/server-sdk.tsx

@@ -1,21 +1,60 @@
+import type { OpenCodeEvent } from "@opencode-ai/client/promise"
 import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
-import { createSdkForServer } from "@/utils/server"
+import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
+import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
 import { useLanguage } from "./language"
 import { usePlatform } from "./platform"
 import { ServerConnection, useServer } from "./server"
 import { createRefCountMap } from "@/utils/refcount"
 import { useGlobal } from "./global"
 import { ServerScope } from "@/utils/server-scope"
+import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
+import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
 
 const isAbortError = (error: unknown) =>
   error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
 
 const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
-type QueuedServerEvent = { directory: string; payload: Event }
+export type ServerEvent = Event & { current?: OpenCodeEvent }
+type QueuedServerEvent = { directory: string; payload: ServerEvent }
+type CurrentDelta = Extract<
+  OpenCodeEvent,
+  { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
+>
+
+export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
+  if (event.type === "permission.v2.asked") {
+    return {
+      id: event.id,
+      type: "permission.asked",
+      properties: {
+        id: event.data.id,
+        sessionID: event.data.sessionID,
+        permission: event.data.action,
+        patterns: event.data.resources,
+        always: event.data.save ?? [],
+        metadata: event.data.metadata ?? {},
+        tool:
+          event.data.source?.type === "tool"
+            ? { messageID: event.data.source.messageID, callID: event.data.source.callID }
+            : undefined,
+      },
+      current: event,
+    } as ServerEvent
+  }
+  if (event.type === "permission.v2.replied")
+    return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
+  if (event.type === "question.v2.asked")
+    return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
+  if (event.type === "question.v2.replied")
+    return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
+  if (event.type === "question.v2.rejected")
+    return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
+  return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
+}
 
 const coalescedKey = (event: QueuedServerEvent) => {
   if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
 export function coalesceServerEvents(events: QueuedServerEvent[]) {
   const output: QueuedServerEvent[] = []
   events.forEach((event) => {
+    const current = currentDelta(event.payload.current)
+    if (current) {
+      const previous = output[output.length - 1]
+      const prior = currentDelta(previous?.payload.current)
+      if (
+        previous &&
+        prior &&
+        previous.directory === event.directory &&
+        currentDeltaKey(prior) === currentDeltaKey(current)
+      ) {
+        const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
+        const data =
+          current.type === "session.compaction.delta"
+            ? { ...current.data, text: fragment }
+            : { ...current.data, delta: fragment }
+        output[output.length - 1] = {
+          directory: event.directory,
+          payload: {
+            ...event.payload,
+            properties: data,
+            current: { ...current, data } as CurrentDelta,
+          } as ServerEvent,
+        }
+        return
+      }
+      output.push(event)
+      return
+    }
     if (event.payload.type !== "message.part.delta") {
       output.push(event)
       return
@@ -71,12 +138,53 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
   return output
 }
 
+function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
+  if (
+    event?.type === "session.text.delta" ||
+    event?.type === "session.reasoning.delta" ||
+    event?.type === "session.tool.input.delta" ||
+    event?.type === "session.compaction.delta"
+  )
+    return event
+}
+
+function currentDeltaKey(event: CurrentDelta) {
+  if (event.type === "session.tool.input.delta")
+    return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
+  if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
+  return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
+}
+
+function currentDeltaFragment(event: CurrentDelta) {
+  return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
+}
+
 export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
   if (!event.persisted) return
   start()
 }
 
-function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
+type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
+type ServerSDKBase = {
+  server: ServerConnection.Any
+  scope: ServerScope
+  protocol: Promise<ServerProtocol>
+  protocolKind: Accessor<ServerProtocol | undefined>
+  url: string
+  client: ReturnType<typeof createSdkForServer>
+  api: CompatibleApi
+  currentApi: ServerApi
+  event: {
+    on: ServerEventEmitter["on"]
+    listen: ServerEventEmitter["listen"]
+    start: () => Promise<void> | undefined
+  }
+  createClient: (
+    opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
+  ) => ReturnType<typeof createSdkForServer>
+}
+
+function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
   const platform = usePlatform()
   const abort = new AbortController()
 
@@ -91,13 +199,19 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     }
   })()
 
+  const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
   const eventSdk = createSdkForServer({
     signal: abort.signal,
     fetch: eventFetch,
     server: server.http,
   })
+  const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
+  const [protocolKind] = createResource(
+    () => protocol,
+    (value) => value,
+  )
   const emitter = createGlobalEmitter<{
-    [key: string]: Event
+    [key: string]: ServerEvent
   }>()
 
   type Queued = QueuedServerEvent
@@ -142,21 +256,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
   let run: Promise<void> | undefined
   let started = false
   let generation = 0
-  const HEARTBEAT_TIMEOUT_MS = 15_000
-  let lastEventAt = Date.now()
-  let heartbeat: ReturnType<typeof setTimeout> | undefined
-  const resetHeartbeat = () => {
-    lastEventAt = Date.now()
-    if (heartbeat) clearTimeout(heartbeat)
-    heartbeat = setTimeout(() => {
-      attempt?.abort()
-    }, HEARTBEAT_TIMEOUT_MS)
-  }
-  const clearHeartbeat = () => {
-    if (!heartbeat) return
-    clearTimeout(heartbeat)
-    heartbeat = undefined
-  }
 
   const start = () => {
     if (started) return run
@@ -168,35 +267,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
       // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
       while (!abort.signal.aborted && started && generation === active) {
         attempt = new AbortController()
-        lastEventAt = Date.now()
         const onAbort = () => {
           attempt?.abort()
         }
         abort.signal.addEventListener("abort", onAbort)
         try {
-          const events = await eventSdk.global.event({
-            signal: attempt.signal,
-            onSseError: (error) => {
-              if (isStreamClosed(error, attempt?.signal)) return
-              if (streamErrorLogged) return
-              streamErrorLogged = true
-              console.error("[global-sdk] event stream error", {
-                url: server.http.url,
-                fetch: eventFetch ? "platform" : "webview",
-                error,
-              })
-            },
-          })
+          const kind = await protocol
+          const events =
+            kind === "v1"
+              ? (await eventSdk.global.event({ signal: attempt.signal })).stream
+              : eventApi.event.subscribe({ signal: attempt.signal })
           let yielded = Date.now()
-          resetHeartbeat()
-          for await (const event of events.stream) {
-            resetHeartbeat()
+          for await (const event of events) {
             streamErrorLogged = false
-            if (event.payload.type !== "sync") {
-              const directory = event.directory ?? "global"
-              const payload = event.payload as Event
-              if (enqueueServerEvent(queue, { directory, payload })) schedule()
-            }
+            const legacy = "payload" in event
+            if (legacy && event.payload.type === "sync") continue
+            const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
+            const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
+            if (enqueueServerEvent(queue, { directory, payload })) schedule()
 
             if (Date.now() - yielded < STREAM_YIELD_MS) continue
             yielded = Date.now()
@@ -214,7 +302,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
         } finally {
           abort.signal.removeEventListener("abort", onAbort)
           attempt = undefined
-          clearHeartbeat()
         }
 
         if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +320,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     started = false
     generation++
     attempt?.abort()
-    clearHeartbeat()
   }
 
   onMount(() => {
     makeEventListener(window, "pagehide", stop)
     makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
-    makeEventListener(document, "visibilitychange", () => {
-      if (document.visibilityState !== "visible") return
-      if (!started) return
-      if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
-      attempt?.abort()
-    })
   })
 
   onCleanup(() => {
@@ -258,12 +338,25 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     fetch: platform.fetch,
     throwOnError: true,
   })
+  const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
+  const legacy = (directory?: string) =>
+    createSdkForServer({
+      server: server.http,
+      fetch: platform.fetch,
+      throwOnError: true,
+      directory,
+    })
+  const api = createCompatibleApi({ protocol, current: currentApi, legacy })
 
   return {
     server,
     scope,
+    protocol,
+    protocolKind,
     url: server.http.url,
     client: sdk,
+    api,
+    currentApi,
     event: {
       on: emitter.on.bind(emitter),
       listen: emitter.listen.bind(emitter),
@@ -279,7 +372,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
   }
 }
 
-type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
 export type ServerSDK = ServerSDKBase & {
   ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
 }
@@ -308,8 +400,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
   },
 })
 
+export function useServerProtocol() {
+  const serverSDK = useServerSDK()
+  return createMemo(() => serverSDK().protocolKind())
+}
+
 type SDKEventMap = {
-  [key in Event["type"]]: Extract<Event, { type: key }>
+  [key in Event["type"]]: Extract<ServerEvent, { type: key }>
 }
 
 function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -327,8 +424,15 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
 
   return {
     scope: serverSDK.scope,
+    protocol: serverSDK.protocol,
     directory,
     client,
+    api: createCompatibleApi({
+      protocol: serverSDK.protocol,
+      current: serverSDK.currentApi,
+      legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
+      directory,
+    }),
     event: emitter,
     get url() {
       return serverSDK.url

+ 156 - 0
packages/app/src/context/server-session-v2-reducer.test.ts

@@ -0,0 +1,156 @@
+import { describe, expect, test } from "bun:test"
+import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
+import { createV2SessionReducer } from "./server-session-v2-reducer"
+
+const event = (input: object) => input as OpenCodeEvent
+const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
+
+describe("v2 session reducer", () => {
+  test("projects promoted input and streaming assistant content", () => {
+    const reducer = createV2SessionReducer()
+    let messages: SessionMessageInfo[] = []
+    const apply = (input: object) => {
+      const result = reducer.reduce(messages, event(input))
+      if (result) messages = result.messages
+      return result
+    }
+
+    apply({
+      ...base,
+      id: "evt_admitted",
+      type: "session.input.admitted",
+      data: {
+        sessionID: "ses_1",
+        inputID: "msg_user",
+        input: { type: "user", delivery: "steer", data: { text: "hello" } },
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_promoted",
+      type: "session.input.promoted",
+      data: { sessionID: "ses_1", inputID: "msg_user" },
+    })
+    apply({
+      ...base,
+      id: "evt_step",
+      type: "session.step.started",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_text_start",
+      type: "session.text.started",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
+    })
+    apply({
+      ...base,
+      id: "evt_text_delta",
+      type: "session.text.delta",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
+    })
+    apply({
+      ...base,
+      id: "evt_text_end",
+      type: "session.text.ended",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
+    })
+
+    expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
+    expect(messages[1]).toMatchObject({
+      id: "msg_assistant",
+      type: "assistant",
+      content: [{ type: "text", text: "hello" }],
+    })
+  })
+
+  test("folds tool, retry, and completion events", () => {
+    const reducer = createV2SessionReducer()
+    let messages: SessionMessageInfo[] = []
+    const apply = (input: object) => {
+      const result = reducer.reduce(messages, event(input))
+      if (result) messages = result.messages
+    }
+
+    apply({
+      ...base,
+      id: "evt_step",
+      type: "session.step.started",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_start",
+      type: "session.tool.input.started",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_delta",
+      type: "session.tool.input.delta",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_called",
+      type: "session.tool.called",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_success",
+      type: "session.tool.success",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        callID: "call_1",
+        structured: {},
+        content: [{ type: "text", text: "done" }],
+        executed: true,
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_retry",
+      type: "session.retry.scheduled",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        attempt: 2,
+        at: 10,
+        error: { type: "ProviderError", message: "retry" },
+      },
+    })
+    apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
+
+    expect(messages[0]).toMatchObject({
+      type: "assistant",
+      retry: undefined,
+      content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
+    })
+  })
+
+  test("requests hydration when promotion admission was missed", () => {
+    const result = createV2SessionReducer().reduce(
+      [],
+      event({
+        ...base,
+        id: "evt_promoted",
+        type: "session.input.promoted",
+        data: { sessionID: "ses_1", inputID: "msg_user" },
+      }),
+    )
+
+    expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
+  })
+})

+ 502 - 0
packages/app/src/context/server-session-v2-reducer.ts

@@ -0,0 +1,502 @@
+import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
+
+type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
+type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
+type Shell = Extract<SessionMessageInfo, { type: "shell" }>
+
+export type V2SessionReduction = {
+  sessionID: string
+  messages: SessionMessageInfo[]
+  touched: string[]
+  missing?: string
+}
+
+export function createV2SessionReducer() {
+  const pending = new Map<string, SessionPendingMessage>()
+
+  const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
+    if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
+    const sessionID = event.data.sessionID
+    const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
+      sessionID,
+      messages,
+      touched,
+    })
+    const append = (message: SessionMessageInfo) =>
+      result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
+
+    switch (event.type) {
+      case "session.input.admitted":
+        pending.set(key(sessionID, event.data.inputID), event.data.input)
+        return result([...source])
+      case "session.input.promoted": {
+        const input = pending.get(key(sessionID, event.data.inputID))
+        pending.delete(key(sessionID, event.data.inputID))
+        if (!input) return { ...result([...source]), missing: event.data.inputID }
+        if (input.type === "user")
+          return append({
+            id: event.data.inputID,
+            type: "user",
+            metadata: input.data.metadata,
+            text: input.data.text,
+            files: input.data.files,
+            agents: input.data.agents,
+            time: { created: event.created },
+          })
+        return append({
+          id: event.data.inputID,
+          type: "synthetic",
+          metadata: input.data.metadata,
+          text: input.data.text,
+          description: input.data.description,
+          time: { created: event.created },
+        })
+      }
+      case "session.agent.selected":
+        return append({
+          id: messageID(event.id),
+          type: "agent-switched",
+          metadata: event.metadata,
+          agent: event.data.agent,
+          time: { created: event.created },
+        })
+      case "session.model.selected":
+        return append({
+          id: messageID(event.id),
+          type: "model-switched",
+          metadata: event.metadata,
+          model: event.data.model,
+          previous: source.findLast(
+            (item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
+              item.type === "model-switched" || item.type === "assistant",
+          )?.model,
+          time: { created: event.created },
+        })
+      case "session.synthetic":
+        return append({
+          id: messageID(event.id),
+          type: "synthetic",
+          metadata: event.data.metadata,
+          text: event.data.text,
+          description: event.data.description,
+          time: { created: event.created },
+        })
+      case "session.skill.activated":
+        return append({
+          id: messageID(event.id),
+          type: "skill",
+          metadata: event.metadata,
+          skill: event.data.id,
+          name: event.data.name,
+          text: event.data.text,
+          time: { created: event.created },
+        })
+      case "session.shell.started":
+        return append({
+          id: messageID(event.id),
+          type: "shell",
+          metadata: event.metadata,
+          shellID: event.data.shell.id,
+          command: event.data.shell.command,
+          status: event.data.shell.status,
+          exit: event.data.shell.exit,
+          time: { created: event.created },
+        })
+      case "session.shell.ended":
+        return updateMessage<Shell>(
+          source,
+          (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
+          (item) => ({
+            ...item,
+            status: event.data.shell.status,
+            exit: event.data.shell.exit,
+            output: event.data.output,
+            time: { ...item.time, completed: event.created },
+          }),
+          sessionID,
+        )
+      case "session.step.started": {
+        const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
+        const completed =
+          current && current.id !== event.data.assistantMessageID
+            ? update(source, current.id, (item) =>
+                item.type === "assistant"
+                  ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
+                  : item,
+              )
+            : [...source]
+        const existing = completed.find((item) => item.id === event.data.assistantMessageID)
+        if (existing?.type === "assistant")
+          return result(
+            update(completed, existing.id, (item) =>
+              item.type === "assistant"
+                ? {
+                    ...item,
+                    agent: event.data.agent,
+                    model: event.data.model,
+                    retry: undefined,
+                    error: undefined,
+                    finish: undefined,
+                    snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
+                    time: { ...item.time, completed: undefined },
+                  }
+                : item,
+            ),
+            current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
+          )
+        return result(
+          [
+            ...completed,
+            {
+              id: event.data.assistantMessageID,
+              type: "assistant",
+              metadata: event.metadata,
+              agent: event.data.agent,
+              model: event.data.model,
+              content: [],
+              snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
+              time: { created: event.created },
+            },
+          ],
+          current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
+        )
+      }
+      case "session.step.ended":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          finish: event.data.finish,
+          cost: event.data.cost,
+          tokens: event.data.tokens,
+          snapshot:
+            event.data.snapshot || event.data.files
+              ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
+              : item.snapshot,
+          time: { ...item.time, completed: event.created },
+        }))
+      case "session.step.failed":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          finish: "error",
+          error: event.data.error,
+          retry: undefined,
+          cost: event.data.cost ?? item.cost,
+          tokens: event.data.tokens ?? item.tokens,
+          snapshot:
+            event.data.snapshot || event.data.files
+              ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
+              : item.snapshot,
+          time: { ...item.time, completed: event.created },
+        }))
+      case "session.text.started":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
+        }))
+      case "session.text.delta":
+        return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
+          ...item,
+          text: item.text + event.data.delta,
+        }))
+      case "session.text.ended":
+        return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
+          ...item,
+          text: event.data.text,
+        }))
+      case "session.reasoning.started":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
+            type: "reasoning",
+            text: "",
+            state: event.data.state,
+            time: { created: event.created },
+          }),
+        }))
+      case "session.reasoning.delta":
+        return updateContent(
+          source,
+          event.data.assistantMessageID,
+          sessionID,
+          "reasoning",
+          event.data.ordinal,
+          (item) => ({
+            ...item,
+            text: item.text + event.data.delta,
+          }),
+        )
+      case "session.reasoning.ended":
+        return updateContent(
+          source,
+          event.data.assistantMessageID,
+          sessionID,
+          "reasoning",
+          event.data.ordinal,
+          (item) => ({
+            ...item,
+            text: event.data.text,
+            state: event.data.state ?? item.state,
+            time: { created: item.time?.created ?? event.created, completed: event.created },
+          }),
+        )
+      case "session.tool.input.started":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
+            ? item.content
+            : [
+                ...item.content,
+                {
+                  type: "tool",
+                  id: event.data.callID,
+                  name: event.data.name,
+                  state: { status: "streaming", input: "" },
+                  time: { created: event.created },
+                },
+              ],
+        }))
+      case "session.tool.input.delta":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
+          tool.state.status === "streaming"
+            ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
+            : tool,
+        )
+      case "session.tool.input.ended":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
+          tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
+        )
+      case "session.tool.called":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
+          ...tool,
+          executed: event.data.executed,
+          providerState: event.data.state,
+          state: { status: "running", input: event.data.input, structured: {}, content: [] },
+          time: { ...tool.time, ran: event.created },
+        }))
+      case "session.tool.progress":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
+          tool.state.status === "running"
+            ? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
+            : tool,
+        )
+      case "session.tool.success":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
+          if (tool.state.status !== "running") return tool
+          return {
+            ...tool,
+            executed: event.data.executed || tool.executed === true,
+            providerResultState: event.data.resultState,
+            state: {
+              status: "completed",
+              input: tool.state.input,
+              structured: event.data.structured,
+              content: event.data.content,
+              result: event.data.result,
+            },
+            time: { ...tool.time, completed: event.created },
+          }
+        })
+      case "session.tool.failed":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
+          if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
+          return {
+            ...tool,
+            executed: event.data.executed || tool.executed === true,
+            providerResultState: event.data.resultState,
+            state: {
+              status: "error",
+              input: typeof tool.state.input === "string" ? {} : tool.state.input,
+              structured: tool.state.status === "running" ? tool.state.structured : {},
+              content: tool.state.status === "running" ? tool.state.content : [],
+              error: event.data.error,
+              result: event.data.result,
+            },
+            time: { ...tool.time, completed: event.created },
+          }
+        })
+      case "session.retry.scheduled":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
+        }))
+      case "session.execution.succeeded":
+      case "session.execution.failed":
+      case "session.execution.interrupted": {
+        const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
+        if (!current?.retry) return result([...source])
+        return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
+      }
+      case "session.compaction.started":
+        return append({
+          id: event.data.inputID ?? messageID(event.id),
+          type: "compaction",
+          status: "running",
+          metadata: event.metadata,
+          reason: event.data.reason,
+          summary: "",
+          recent: event.data.recent,
+          time: { created: event.created },
+        })
+      case "session.compaction.delta":
+        return updateMessage<Extract<Compaction, { status: "running" }>>(
+          source,
+          (item): item is Extract<Compaction, { status: "running" }> =>
+            item.type === "compaction" && item.status === "running",
+          (item) => ({
+            ...item,
+            summary: item.summary + event.data.text,
+          }),
+          sessionID,
+        )
+      case "session.compaction.ended": {
+        const current = source.findLast(
+          (item): item is Extract<Compaction, { status: "running" }> =>
+            item.type === "compaction" && item.status === "running",
+        )
+        if (!current)
+          return append({
+            id: messageID(event.id),
+            type: "compaction",
+            status: "completed",
+            metadata: event.metadata,
+            reason: event.data.reason,
+            summary: event.data.text,
+            recent: event.data.recent,
+            time: { created: event.created },
+          })
+        return result(
+          update(source, current.id, () => ({
+            ...current,
+            status: "completed",
+            reason: event.data.reason,
+            summary: event.data.text,
+            recent: event.data.recent,
+          })),
+          [current.id],
+        )
+      }
+      case "session.compaction.failed": {
+        const current = source.findLast(
+          (item): item is Extract<Compaction, { status: "running" }> =>
+            item.type === "compaction" && item.status === "running",
+        )
+        const failed: Extract<Compaction, { status: "failed" }> = {
+          id: current?.id ?? event.data.inputID ?? messageID(event.id),
+          type: "compaction",
+          status: "failed",
+          metadata: current?.metadata ?? event.metadata,
+          reason: event.data.reason,
+          error: event.data.error,
+          time: current?.time ?? { created: event.created },
+        }
+        if (!current) return append(failed)
+        return result(
+          update(source, current.id, () => failed),
+          [failed.id],
+        )
+      }
+      default:
+        return
+    }
+  }
+
+  return {
+    reduce,
+    clear(sessionID: string) {
+      for (const id of pending.keys()) {
+        if (id.startsWith(`${sessionID}:`)) pending.delete(id)
+      }
+    },
+  }
+}
+
+function key(sessionID: string, inputID: string) {
+  return `${sessionID}:${inputID}`
+}
+
+function messageID(eventID: string) {
+  return eventID.replace(/^evt_/, "msg_")
+}
+
+function update(
+  source: readonly SessionMessageInfo[],
+  id: string,
+  apply: (item: SessionMessageInfo) => SessionMessageInfo,
+) {
+  return source.map((item) => (item.id === id ? apply(item) : item))
+}
+
+function updateMessage<T extends SessionMessageInfo>(
+  source: readonly SessionMessageInfo[],
+  matches: (item: SessionMessageInfo) => item is T,
+  apply: (item: T) => T,
+  sessionID: string,
+): V2SessionReduction {
+  const current = source.findLast(matches)
+  if (!current) return { sessionID, messages: [...source], touched: [] }
+  return {
+    sessionID,
+    messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
+    touched: [current.id],
+  }
+}
+
+function updateAssistant(
+  source: readonly SessionMessageInfo[],
+  id: string,
+  sessionID: string,
+  apply: (item: Assistant) => Assistant,
+): V2SessionReduction {
+  return {
+    sessionID,
+    messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
+    touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
+  }
+}
+
+function updateContent<T extends "text" | "reasoning">(
+  source: readonly SessionMessageInfo[],
+  messageID: string,
+  sessionID: string,
+  type: T,
+  ordinal: number,
+  apply: (
+    item: Extract<Assistant["content"][number], { type: T }>,
+  ) => Extract<Assistant["content"][number], { type: T }>,
+) {
+  return updateAssistant(source, messageID, sessionID, (assistant) => {
+    let index = -1
+    return {
+      ...assistant,
+      content: assistant.content.map((item) => {
+        if (item.type !== type || ++index !== ordinal) return item
+        return apply(item as Extract<Assistant["content"][number], { type: T }>)
+      }),
+    }
+  })
+}
+
+function updateTool(
+  source: readonly SessionMessageInfo[],
+  messageID: string,
+  callID: string,
+  sessionID: string,
+  apply: (
+    item: Extract<Assistant["content"][number], { type: "tool" }>,
+  ) => Extract<Assistant["content"][number], { type: "tool" }>,
+) {
+  return updateAssistant(source, messageID, sessionID, (assistant) => ({
+    ...assistant,
+    content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
+  }))
+}
+
+function insertOrdinal<T extends Assistant["content"][number]["type"]>(
+  source: Assistant["content"],
+  type: T,
+  ordinal: number,
+  item: Extract<Assistant["content"][number], { type: T }>,
+) {
+  const matches = source.filter((content) => content.type === type)
+  if (matches[ordinal]) return source
+  return [...source, item]
+}

+ 162 - 0
packages/app/src/context/server-session.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { retry } from "@opencode-ai/core/util/retry"
+import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
 import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
 import { createServerSession } from "./server-session"
 
@@ -158,6 +159,57 @@ function setup(sessions: Record<string, Session>) {
 }
 
 describe("server session", () => {
+  test("projects V2 session events into current and legacy message state", () => {
+    const ctx = setup({ child: session("child") })
+    ctx.store.remember(session("child"))
+    ctx.store.set("session_message", "child", [
+      {
+        id: "msg_1_user",
+        type: "user",
+        text: "hello",
+        time: { created: 1 },
+      },
+    ])
+    const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
+
+    apply({
+      id: "evt_step",
+      created: 2,
+      type: "session.step.started",
+      durable: { aggregateID: "child", seq: 1, version: 1 },
+      location: { directory: "/repo" },
+      data: {
+        sessionID: "child",
+        assistantMessageID: "msg_2_assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    apply({
+      id: "evt_text_start",
+      created: 3,
+      type: "session.text.started",
+      durable: { aggregateID: "child", seq: 2, version: 1 },
+      location: { directory: "/repo" },
+      data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
+    })
+    apply({
+      id: "evt_text_delta",
+      created: 4,
+      type: "session.text.delta",
+      location: { directory: "/repo" },
+      data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
+    })
+
+    expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
+      id: "msg_2_assistant",
+      type: "assistant",
+      content: [{ type: "text", text: "world" }],
+    })
+    expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
+    expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
+  })
+
   test("resolves lineage by session ID without directory", async () => {
     const ctx = setup({ child: session("child", "root"), root: session("root") })
 
@@ -178,6 +230,116 @@ describe("server session", () => {
     expect(ctx.store.data.message.root).toEqual([])
   })
 
+  test("loads current session content through the current message API", async () => {
+    const requests: unknown[] = []
+    const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
+    const assistant = {
+      id: "msg_a_assistant",
+      type: "assistant",
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+      content: [{ type: "text", text: "hi" }],
+      time: { created: 2, completed: 3 },
+    }
+    const client = {
+      session: {
+        messages: () => {
+          throw new Error("legacy message endpoint called")
+        },
+      },
+    } as unknown as OpencodeClient
+    const messageApi = {
+      list: async (input: unknown) => {
+        requests.push(input)
+        return { data: [assistant, user], cursor: { previous: null, next: null } }
+      },
+    } as unknown as MessageApi
+    const store = createServerSession(client, {} as SessionApi, messageApi)
+    store.remember(session("root"))
+
+    await store.sync("root")
+
+    expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+  })
+
+  test("extends a current page to include the user for split assistant turns", async () => {
+    const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
+    const assistant = (id: string, created: number) => ({
+      id,
+      type: "assistant" as const,
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+      content: [{ type: "text" as const, text: id }],
+      time: { created, completed: created },
+    })
+    const assistants = [
+      assistant("msg_2_assistant", 2),
+      assistant("msg_3_assistant", 3),
+      assistant("msg_4_assistant", 4),
+    ]
+    const pages = [
+      { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
+      { data: [assistants[0], user], cursor: { previous: null, next: null } },
+    ]
+    const requests: unknown[] = []
+    const messageApi = {
+      list: async (input: unknown) => {
+        requests.push(input)
+        return pages.shift()!
+      },
+    } as unknown as MessageApi
+    const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
+    store.remember(session("root"))
+
+    await store.sync("root")
+
+    expect(requests).toEqual([
+      { sessionID: "root", limit: 20, order: "desc" },
+      { sessionID: "root", limit: 20, cursor: "older" },
+    ])
+    expect(store.data.message.root.map((message) => message.id)).toEqual([
+      user.id,
+      ...assistants.map((item) => item.id),
+    ])
+    expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
+  })
+
+  test("indexes V1 messages for the current timeline projection", async () => {
+    const user = userMessage("message-1", { sessionID: "root" })
+    const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
+    const client = messageClient(
+      response([
+        { info: user, parts: [textPart(user.id, { sessionID: "root" })] },
+        { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
+      ]),
+    )
+    const messageApi = {
+      list: () => {
+        throw new Error("current message endpoint called")
+      },
+    } as unknown as MessageApi
+    const store = createServerSession(client, {} as SessionApi, messageApi, {
+      protocol: Promise.resolve("v1"),
+    })
+    store.remember(session("root"))
+
+    await store.sync("root")
+
+    expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+    expect(store.data.session_message.root).toMatchObject([
+      { id: user.id, type: "user", text: "text" },
+      { id: assistant.id, type: "assistant" },
+    ])
+
+    const next = userMessage("message-3", { sessionID: "root" })
+    store.apply({ type: "message.updated", properties: { info: next } })
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
+
+    store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+  })
+
   test("backfills an assistant-only initial page through its user root", async () => {
     const user = userMessage("message-1")
     const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]

+ 267 - 38
packages/app/src/context/server-session.ts

@@ -1,5 +1,6 @@
 import { Binary } from "@opencode-ai/core/util/binary"
 import { retry } from "@opencode-ai/core/util/retry"
+import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
 import type {
   Message,
   OpencodeClient,
@@ -8,15 +9,18 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
-  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { batch } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
-import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
+import { message as cleanMessage } from "@/utils/diffs"
 import { sessionNotFoundError } from "@/utils/server-errors"
 import { rootSession } from "@/utils/session-route"
+import { normalizeSessionInfo } from "@/utils/session"
+import { normalizeSessionMessages } from "@/utils/session-message"
 import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
+import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
@@ -26,6 +30,17 @@ const historyMessagePageSize = 200
 const sessionInfoLimit = 2_048
 const emptyIDs: ReadonlySet<string> = new Set()
 
+function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
+  const boundary = source.find(
+    (message) =>
+      message.type === "user" ||
+      message.type === "shell" ||
+      message.type === "assistant" ||
+      (message.type === "synthetic" && message.description?.trim()),
+  )
+  return boundary?.type === "assistant"
+}
+
 type OptimisticItem = {
   message: Message
   parts: Part[]
@@ -36,10 +51,37 @@ type OptimisticItem = {
 type MessagePage = {
   session: Message[]
   part: { id: string; part: Part[] }[]
+  source?: SessionMessageInfo[]
+  sourceMode?: "latest" | "older"
+  projectSource?: boolean
   cursor?: string
   complete: boolean
 }
 
+function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
+  return items
+    .slice()
+    .sort((a, b) => cmp(a.info.id, b.info.id))
+    .map((item) => {
+      if (item.info.role === "user") {
+        return {
+          id: item.info.id,
+          type: "user" as const,
+          text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
+          time: item.info.time,
+        }
+      }
+      return {
+        id: item.info.id,
+        type: "assistant" as const,
+        agent: item.info.agent ?? item.info.mode,
+        model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
+        content: [],
+        time: item.info.time,
+      }
+    })
+}
+
 // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
 type MessageLoadState = {
   touchedMessages: Set<string>
@@ -52,6 +94,7 @@ type MessageLoadState = {
   optimisticParts: Map<string, Set<string>>
   orphanParents: Set<string>
   clearedMessageParts: Set<string>
+  touchedSource: Set<string>
 }
 
 type MessageLoadBaseline = Pick<
@@ -137,15 +180,25 @@ function reconcileFetched<T extends { id: string }>(
   return [...result.values()].sort((a, b) => cmp(a.id, b.id))
 }
 
-export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
+type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
+
+export function createServerSession(
+  client: OpencodeClient,
+  sessionApiOrOptions?: SessionApi | ServerSessionOptions,
+  messageApi?: MessageApi,
+  currentOptions?: ServerSessionOptions,
+) {
+  const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
+  const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
   const [data, setData] = createStore({
     info: {} as Record<string, Session | undefined>,
     session_status: {} as Record<string, SessionStatus>,
-    session_diff: {} as Record<string, SnapshotFileDiff[]>,
+    session_diff: {} as Record<string, FileDiffInfo[]>,
     todo: {} as Record<string, Todo[]>,
     permission: {} as Record<string, PermissionRequest[]>,
     question: {} as Record<string, QuestionRequest[]>,
     message: {} as Record<string, Message[]>,
+    session_message: {} as Record<string, SessionMessageInfo[]>,
     part: {} as Record<string, Part[]>,
     part_text_accum_delta: {} as Record<string, string>,
     session_working(id: string) {
@@ -154,9 +207,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
   })
   const requests = new Map<string, Promise<Session>>()
   const inflight = new Map<string, Promise<void>>()
-  const inflightDiff = new Map<string, Promise<void>>()
   const inflightTodo = new Map<string, Promise<void>>()
   const optimistic = new Map<string, Map<string, OptimisticItem>>()
+  const v2 = createV2SessionReducer()
   const messageLoads = new Map<string, MessageLoadState>()
   const pendingParts = new Map<string, Map<string, Set<string>>>()
   const orphanParts = new Map<string, Set<string>>()
@@ -191,6 +244,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     at: {} as Record<string, number | undefined>,
   })
 
+  const indexLegacyMessage = (message: Message) => {
+    const current = data.session_message[message.sessionID] ?? []
+    if (current.some((item) => item.id === message.id)) return
+    setData(
+      "session_message",
+      message.sessionID,
+      reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
+    )
+  }
+
   const remember = (session: Session) => {
     setData("info", session.id, reconcile(session))
     infoSeen.delete(session.id)
@@ -200,7 +263,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         ...pinned.keys(),
         ...requests.keys(),
         ...inflight.keys(),
-        ...inflightDiff.keys(),
         ...inflightTodo.keys(),
         ...messageLoads.keys(),
         ...optimistic.keys(),
@@ -242,27 +304,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     const pending = requests.get(sessionID)
     if (pending) return pending
     const active = generation(sessionID)
-    const request = client.session.get({ sessionID }).then((result) => {
-      if (!result.data) throw sessionNotFoundError(sessionID)
-      if (generations.get(sessionID) !== active) return result.data
-      return remember(result.data)
+    const request = sessionApi
+      ? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
+      : client.session.get({ sessionID }).then((result) => {
+          if (!result.data) throw sessionNotFoundError(sessionID)
+          return result.data
+        })
+    const resolved = request.then((result) => {
+      if (generations.get(sessionID) !== active) return result
+      return remember(result)
     })
-    requests.set(sessionID, request)
+    requests.set(sessionID, resolved)
     const cleanup = () => {
-      if (requests.get(sessionID) === request) requests.delete(sessionID)
+      if (requests.get(sessionID) === resolved) requests.delete(sessionID)
       if (
         generations.get(sessionID) === active &&
         !data.info[sessionID] &&
         !requests.has(sessionID) &&
         !messageLoads.has(sessionID) &&
         !inflight.has(sessionID) &&
-        !inflightDiff.has(sessionID) &&
         !inflightTodo.has(sessionID)
       )
         generations.delete(sessionID)
     }
-    void request.then(cleanup, cleanup)
-    return request
+    void resolved.then(cleanup, cleanup)
+    return resolved
   }
 
   const peekLineage = (sessionID: string) => {
@@ -419,9 +485,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       clearOptimistic(sessionID)
       requests.delete(sessionID)
       inflight.delete(sessionID)
-      inflightDiff.delete(sessionID)
       inflightTodo.delete(sessionID)
       messageLoads.delete(sessionID)
+      v2.clear(sessionID)
       pendingParts.delete(sessionID)
       orphanParts.delete(sessionID)
       removedMessages.delete(sessionID)
@@ -449,7 +515,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       ...pinned.keys(),
       ...requests.keys(),
       ...inflight.keys(),
-      ...inflightDiff.keys(),
       ...inflightTodo.keys(),
       ...messageLoads.keys(),
       ...optimistic.keys(),
@@ -470,6 +535,34 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     )
 
   const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
+    if (messageApi && (await options?.protocol) !== "v1") {
+      const request = (cursor?: string) =>
+        (options?.retry ?? retry)(() => {
+          onAttempt?.()
+          return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
+        })
+      const first = await request(before)
+      const pages = [first]
+      while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
+        const response = await request(pages.at(-1)!.cursor.next ?? undefined)
+        pages.push(response)
+        if (!response.data.length) break
+      }
+      const response = pages.at(-1)!
+      const source = pages.flatMap((page) => page.data).toReversed()
+      const normalized = normalizeSessionMessages(sessionID, source)
+      return {
+        session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
+        part: [...normalized.parts.entries()]
+          .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
+          .sort((a, b) => cmp(a.id, b.id)),
+        source,
+        sourceMode: before ? ("older" as const) : ("latest" as const),
+        projectSource: true,
+        cursor: response.cursor.next ?? undefined,
+        complete: response.data.length === 0,
+      }
+    }
     const response = await (options?.retry ?? retry)(() => {
       onAttempt?.()
       return client.session.messages({ sessionID, limit, before })
@@ -481,12 +574,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         id: item.info.id,
         part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
       })),
+      source: legacyMessageSource(items),
+      sourceMode: before ? ("older" as const) : ("latest" as const),
       cursor: response.response.headers.get("x-next-cursor") ?? undefined,
       complete: !response.response.headers.get("x-next-cursor"),
     }
   }
 
   const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
+    if (sessionApi && (await options?.protocol) !== "v1") {
+      const response = await (options?.retry ?? retry)(() => {
+        onAttempt?.()
+        return sessionApi.message({ sessionID, messageID })
+      })
+      const normalized = normalizeSessionMessages(sessionID, [response])
+      const message = normalized.messages[0]
+      if (!message) throw new Error(`Message not found: ${messageID}`)
+      return { message, parts: normalized.parts.get(messageID) ?? [] }
+    }
     const response = await (options?.retry ?? retry)(() => {
       onAttempt?.()
       return client.session.message({ sessionID, messageID })
@@ -571,7 +676,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     preserveUnfetched: boolean | ((message: Message) => boolean),
     cleanupOrphans: boolean,
   ) => {
-    const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
+    const source = page.source
+      ? (() => {
+          const incoming = new Map(page.source.map((message) => [message.id, message]))
+          const existing = data.session_message[sessionID] ?? []
+          const current = existing.filter((message) => !incoming.has(message.id))
+          const live = new Map(existing.map((message) => [message.id, message]))
+          return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
+            (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
+          )
+        })()
+      : undefined
+    const projected =
+      page.projectSource && source
+        ? (() => {
+            const normalized = normalizeSessionMessages(sessionID, source)
+            return {
+              ...page,
+              session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
+              part: [...normalized.parts.entries()]
+                .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
+                .sort((a, b) => cmp(a.id, b.id)),
+            }
+          })()
+        : page
+    const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
     merged.observed.forEach((item) => {
       if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
     })
@@ -583,6 +712,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       preserveUnfetched,
     })
     batch(() => {
+      if (source) setData("session_message", sessionID, reconcile(source))
       const messageIDs = replaceMessages(sessionID, messages)
       replaceParts(sessionID, merged.part, messageIDs, load)
       const orphans = orphanParts.get(sessionID)
@@ -613,6 +743,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       optimisticParts: new Map(),
       orphanParents: new Set(),
       clearedMessageParts: new Set(),
+      touchedSource: new Set(),
     }
     messageLoads.set(sessionID, load)
     setMeta("loading", sessionID, true)
@@ -747,6 +878,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       return properties.part.sessionID
   }
 
+  const projectV2 = (reduction: V2SessionReduction) => {
+    reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
+    setData("session_message", reduction.sessionID, reconcile(reduction.messages))
+    if (reduction.touched.length === 0) return
+
+    const touched = new Set(reduction.touched)
+    let parentID: string | undefined
+    for (const message of reduction.messages) {
+      if (message.type === "user" || (message.type === "synthetic" && message.description?.trim()))
+        parentID = message.id
+      if (message.type === "shell") {
+        if (touched.has(message.id)) touched.add(`${message.id}:assistant`)
+        parentID = undefined
+      }
+      if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID)
+      if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID)
+    }
+
+    const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
+    batch(() => {
+      for (const message of normalized.messages) {
+        if (!touched.has(message.id)) continue
+        apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
+      }
+      for (const messageID of touched) {
+        const next = normalized.parts.get(messageID) ?? []
+        const nextIDs = new Set(next.map((part) => part.id))
+        for (const part of next) {
+          apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
+        }
+        for (const part of data.part[messageID] ?? []) {
+          if (nextIDs.has(part.id)) continue
+          apply({
+            type: "message.part.removed",
+            properties: { sessionID: reduction.sessionID, messageID, partID: part.id },
+          })
+        }
+      }
+    })
+  }
+
+  const hydrateV2Message = (sessionID: string, messageID: string) => {
+    if (!sessionApi) return
+    void sessionApi
+      .message({ sessionID, messageID })
+      .then((message) => {
+        const current = data.session_message[sessionID] ?? []
+        const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
+        projectV2({ sessionID, messages, touched: [message.id] })
+      })
+      .catch(() => {})
+  }
+
+  const applyV2 = (event: OpenCodeEvent) => {
+    if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
+    const sessionID = event.data.sessionID
+    const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
+    if (reduction) {
+      projectV2(reduction)
+      if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
+    }
+
+    const info = data.info[sessionID]
+    if (event.type === "session.renamed" && info)
+      remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
+    if (event.type === "session.moved" && info)
+      remember({
+        ...info,
+        projectID: event.data.projectID ?? info.projectID,
+        workspaceID: event.data.location.workspaceID,
+        directory: event.data.location.directory,
+        path: event.data.subpath,
+        time: { ...info.time, updated: event.created },
+      })
+    if (event.type === "session.usage.updated" && info)
+      remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
+    if (event.type === "session.archived") {
+      if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
+      evict([sessionID])
+    }
+    if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
+    if (
+      event.type === "session.execution.succeeded" ||
+      event.type === "session.execution.failed" ||
+      event.type === "session.execution.interrupted"
+    )
+      setData("session_status", sessionID, { type: "idle" })
+    if (event.type === "session.retry.scheduled")
+      setData("session_status", sessionID, {
+        type: "retry",
+        attempt: event.data.attempt,
+        message: event.data.error.message,
+        next: event.data.at,
+      })
+    if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
+    if (
+      event.type === "session.revert.staged" ||
+      event.type === "session.revert.cleared" ||
+      event.type === "session.revert.committed"
+    )
+      void resolve(sessionID, { force: true }).catch(() => {})
+  }
+
   const apply = (event: { type: string; properties?: unknown }) => {
     const eventID = eventSessionID(event)
     if (eventID) {
@@ -770,7 +1004,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         return
       }
       case "session.deleted": {
-        const sessionID = (event.properties as { info: Session }).info.id
+        const properties = event.properties as { sessionID?: string; info?: Session }
+        const sessionID = properties.info?.id ?? properties.sessionID
+        if (!sessionID) return
         infoSeen.delete(sessionID)
         setData(
           "info",
@@ -779,11 +1015,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         evict([sessionID])
         return
       }
-      case "session.diff": {
-        const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
-        setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
-        return
-      }
       case "todo.updated": {
         const props = event.properties as { sessionID: string; todos: Todo[] }
         setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
@@ -796,6 +1027,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       }
       case "message.updated": {
         const info = cleanMessage((event.properties as { info: Message }).info)
+        indexLegacyMessage(info)
         const load = messageLoads.get(info.sessionID)
         load?.touchedMessages.add(info.id)
         load?.removedMessages.delete(info.id)
@@ -828,6 +1060,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       }
       case "message.removed": {
         const props = event.properties as { sessionID: string; messageID: string }
+        setData("session_message", props.sessionID, (messages) =>
+          messages?.filter((message) => message.id !== props.messageID),
+        )
         const load = messageLoads.get(props.sessionID)
         load?.touchedMessages.add(props.messageID)
         load?.removedMessages.add(props.messageID)
@@ -1140,23 +1375,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
       },
     },
-    diff(sessionID: string, options?: { force?: boolean }) {
+    async todo(sessionID: string, request?: { force?: boolean }) {
       touch(sessionID)
-      if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
-      return runInflight(inflightDiff, sessionID, () => {
-        const active = generation(sessionID)
-        return retry(() => client.session.diff({ sessionID })).then((result) => {
-          if (generations.get(sessionID) !== active) return
-          setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
-        })
-      })
-    },
-    todo(sessionID: string, options?: { force?: boolean }) {
-      touch(sessionID)
-      if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
+      if (data.todo[sessionID] !== undefined && !request?.force) return
+      if ((await options?.protocol) === "v2") {
+        setData("todo", sessionID, [])
+        return
+      }
       return runInflight(inflightTodo, sessionID, () => {
         const active = generation(sessionID)
-        return retry(() => client.session.todo({ sessionID })).then((result) => {
+        return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
           if (generations.get(sessionID) !== active) return
           setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
         })
@@ -1190,6 +1418,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       if (count && count > 1) pinned.set(sessionID, count - 1)
     },
     apply,
+    applyV2,
   }
 }
 

+ 139 - 30
packages/app/src/context/server-sync.test.ts

@@ -1,6 +1,104 @@
 import { describe, expect, test } from "bun:test"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
+import type {
+  McpApi,
+  McpListInput,
+  McpResourceCatalogInput,
+  SessionApi,
+  SessionInfo,
+  SessionListInput,
+} from "@opencode-ai/client/promise"
+import { QueryClient } from "@tanstack/solid-query"
 import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
-import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
+import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
+import { ServerScope } from "@/utils/server-scope"
+import { createServerSession } from "./server-session"
+
+describe("MCP queries", () => {
+  test("loads current servers for the requested location", async () => {
+    const calls: unknown[] = []
+    const queryClient = new QueryClient()
+    const result = await queryClient.fetchQuery(
+      loadMcpQuery(ServerScope.local, "/project", {
+        list: async (input: McpListInput = {}) => {
+          calls.push(input)
+          return {
+            location: { directory: "/project", project: { id: "project", directory: "/project" } },
+            data: [
+              { name: "docs", status: { status: "connected" } },
+              { name: "search", status: { status: "pending" } },
+            ],
+          }
+        },
+      } as unknown as McpApi),
+    )
+
+    expect(calls).toEqual([{ location: { directory: "/project" } }])
+    expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
+  })
+
+  test("loads and keys the current resource catalog", async () => {
+    const calls: unknown[] = []
+    const queryClient = new QueryClient()
+    const result = await queryClient.fetchQuery(
+      loadMcpResourcesQuery(ServerScope.local, "/project", {
+        resource: {
+          catalog: async (input: McpResourceCatalogInput = {}) => {
+            calls.push(input)
+            return {
+              location: { directory: "/project", project: { id: "project", directory: "/project" } },
+              data: {
+                resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
+                templates: [],
+              },
+            }
+          },
+        },
+      } as unknown as McpApi),
+    )
+
+    expect(calls).toEqual([{ location: { directory: "/project" } }])
+    expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
+  })
+})
+
+describe("active session query", () => {
+  test("loads active sessions immediately and once per server cache", async () => {
+    let calls = 0
+    const queryClient = new QueryClient()
+    const options = loadActiveSessionsQuery(ServerScope.local, {
+      active: async () => {
+        calls++
+        return { ses_running: { type: "running" } }
+      },
+    })
+
+    expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
+    expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
+    expect(calls).toBe(1)
+    expect(options.enabled).toBe(true)
+    expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
+  })
+
+  test("does not overwrite statuses already written by events", () => {
+    const session = createServerSession({} as OpencodeClient)
+    session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
+
+    seedActiveSessionStatuses(session, {
+      ses_running: { type: "running" },
+      ses_retry: { type: "running" },
+    })
+
+    expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
+    expect(session.data.session_status.ses_retry).toEqual({
+      type: "retry",
+      attempt: 2,
+      message: "retrying",
+      next: 10,
+    })
+  })
+})
 
 describe("pickDirectoriesToEvict", () => {
   test("keeps pinned stores and evicts idle stores", () => {
@@ -23,46 +121,57 @@ describe("pickDirectoriesToEvict", () => {
   })
 })
 
-describe("loadRootSessionsWithFallback", () => {
-  test("uses limited roots query when supported", async () => {
-    const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+describe("loadRootSessions", () => {
+  test("loads and normalizes a limited page of root sessions", async () => {
+    const calls: SessionListInput[] = []
 
-    const result = await loadRootSessionsWithFallback({
+    const result = await loadRootSessions({
+      api: {
+        list: async (query = {}) => {
+          calls.push(query)
+          return { data: [sessionInfo("session-1")], cursor: {} }
+        },
+      } satisfies Pick<SessionApi, "list">,
       directory: "dir",
       limit: 10,
-      list: async (query) => {
-        calls.push(query)
-        return { data: [] }
-      },
     })
 
-    expect(result.data).toEqual([])
+    expect(result.data).toEqual([
+      expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
+    ])
     expect(result.limited).toBe(true)
-    expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
+    expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
   })
 
-  test("falls back to full roots query on limited-query failure", async () => {
-    const calls: Array<{ directory: string; roots: true; limit?: number }> = []
-
-    const result = await loadRootSessionsWithFallback({
-      directory: "dir",
-      limit: 25,
-      list: async (query) => {
-        calls.push(query)
-        if (query.limit) throw new Error("unsupported")
-        return { data: [] }
-      },
-    })
-
-    expect(result.data).toEqual([])
-    expect(result.limited).toBe(false)
-    expect(calls).toEqual([
-      { directory: "dir", roots: true, limit: 25 },
-      { directory: "dir", roots: true },
-    ])
+  test("propagates list failures", () => {
+    expect(
+      loadRootSessions({
+        api: {
+          list: async () => {
+            throw new Error("failed")
+          },
+        } satisfies Pick<SessionApi, "list">,
+        directory: "dir",
+        limit: 25,
+      }),
+    ).rejects.toThrow("failed")
   })
 })
 
+function sessionInfo(id: string) {
+  return {
+    id,
+    projectID: "project-1",
+    agent: "build",
+    model: { id: "model-1", providerID: "provider-1" },
+    cost: 0,
+    tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    time: { created: 1, updated: 1 },
+    title: id,
+    location: { directory: "dir" },
+  } as SessionInfo
+}
+
 describe("estimateRootSessionTotal", () => {
   test("keeps exact total for full fetches", () => {
     expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)

+ 232 - 39
packages/app/src/context/server-sync.tsx

@@ -1,10 +1,10 @@
 import type {
   Config,
-  McpResource,
   OpencodeClient,
   Path,
   Project,
   ProviderAuthResponse,
+  SessionStatus,
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
@@ -18,6 +18,7 @@ import {
   bootstrapGlobal,
   clearProviderRev,
   loadAgentsQuery,
+  loadCommands,
   loadGlobalConfigQuery,
   loadPathQuery,
   loadProjectsQuery,
@@ -26,12 +27,13 @@ import {
 } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
 import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
-import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
 import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { formatServerError } from "@/utils/server-errors"
 import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
+import type { SolidQueryOptions } from "@tanstack/solid-query"
 import { createRefreshQueue } from "./global-sync/queue"
 import { directoryKey } from "./global-sync/utils"
 import { PathKey } from "@/utils/path-key"
@@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry"
 import type { ServerScope } from "@/utils/server-scope"
 import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
 import { persisted } from "@/utils/persist"
+import type { ServerApi } from "@/utils/server"
+import type {
+  McpListInput,
+  McpListOutput,
+  McpResource,
+  McpResourceCatalogInput,
+  McpResourceCatalogOutput,
+  McpServer,
+  SessionActiveOutput,
+} from "@opencode-ai/client/promise"
 import { toggleMcp } from "./global-sync/mcp"
-import { createServerSession } from "./server-session"
+import { createServerSession, type ServerSession } from "./server-session"
 
 type GlobalStore = {
   ready: boolean
@@ -59,16 +71,76 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
-export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
-  queryOptions({
+type McpListApi = {
+  readonly list: (input?: McpListInput) => Promise<McpListOutput>
+}
+
+type McpResourceApi = {
+  readonly resource: {
+    readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
+  }
+}
+
+type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
+  initialData?: undefined
+  queryKey: K
+}
+
+type SessionActiveApi = {
+  readonly active: () => Promise<SessionActiveOutput>
+}
+
+export const loadMcpQuery = (
+  scope: ServerScope,
+  directory: string,
+  api: McpListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<"v1" | "v2">,
+): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
+  queryOptions<
+    Record<string, McpServer["status"]>,
+    Error,
+    Record<string, McpServer["status"]>,
+    readonly [ServerScope, string, "mcp"]
+  >({
     queryKey: [scope, directory, "mcp"] as const,
-    queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
+    queryFn: async () => {
+      if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
+      return api
+        .list({ location: { directory } })
+        .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
+    },
   })
 
-export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
-  queryOptions<Record<string, McpResource>>({
+export const loadMcpResourcesQuery = (
+  scope: ServerScope,
+  directory: string,
+  api: McpResourceApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<"v1" | "v2">,
+): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
+  queryOptions<
+    Record<string, McpResource>,
+    Error,
+    Record<string, McpResource>,
+    readonly [ServerScope, string, "mcpResources"]
+  >({
     queryKey: [scope, directory, "mcpResources"] as const,
-    queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
+    queryFn: async () => {
+      if ((await protocol) === "v1" && legacy) {
+        return Object.fromEntries(
+          Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
+            key,
+            { ...resource, server: resource.client },
+          ]),
+        )
+      }
+      return api.resource
+        .catalog({ location: { directory } })
+        .then((result) =>
+          Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
+        )
+    },
     placeholderData: {},
   })
 
@@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
     queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
   })
 
+export const loadActiveSessionsQuery = (
+  scope: ServerScope,
+  api: SessionActiveApi,
+): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
+  queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
+    queryKey: [scope, "activeSessions"] as const,
+    queryFn: () => api.active(),
+    enabled: true,
+    staleTime: Number.POSITIVE_INFINITY,
+    gcTime: Number.POSITIVE_INFINITY,
+    refetchOnMount: false,
+    refetchOnReconnect: false,
+    refetchOnWindowFocus: false,
+  })
+
+export function seedActiveSessionStatuses(
+  session: Pick<ServerSession, "data" | "set">,
+  active: SessionActiveOutput | Record<string, SessionStatus>,
+) {
+  for (const sessionID of Object.keys(active)) {
+    if (session.data.session_status[sessionID] !== undefined) continue
+    const status = active[sessionID]
+    session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
+  }
+}
+
 function makeQueryOptionsApi(
   scope: ServerScope,
   serverSDK: () => OpencodeClient,
+  serverAPI: ServerApi,
   sdkFor: (dir: PathKey) => OpencodeClient,
+  protocol: Promise<"v1" | "v2">,
 ) {
   return {
     globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
-    projects: () => loadProjectsQuery(scope, serverSDK()),
+    projects: () => loadProjectsQuery(scope, serverAPI.project),
     providers: (directory: PathKey | null) =>
-      loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
-    path: (directory: PathKey | null) =>
-      loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
-    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
-    references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
-    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
-    mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
+      loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
+    path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
+    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
+    references: (directory: PathKey) =>
+      loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
+    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
+    mcpResources: (directory: PathKey) =>
+      loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
     lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
     sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
   }
@@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     return sdk
   }
 
-  const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
+  const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
+    protocol: serverSDK.protocol,
+  })
+  const queryOptionsApi = makeQueryOptionsApi(
+    serverSDK.scope,
+    () => serverSDK.client,
+    serverSDK.api,
+    sdkFor,
+    serverSDK.protocol,
+  )
 
   const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
     queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
   }))
+  const activeSessionsQuery = useQuery(() =>
+    loadActiveSessionsQuery(serverSDK.scope, {
+      active: async () => {
+        if ((await serverSDK.protocol) === "v1") {
+          const statuses = (await serverSDK.client.session.status()).data ?? {}
+          seedActiveSessionStatuses(session, statuses)
+          for (const sessionID of Object.keys(statuses)) {
+            void session.resolve(sessionID).catch(() => undefined)
+          }
+          return Object.fromEntries(
+            Object.entries(statuses).flatMap(([sessionID, status]) =>
+              status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
+            ),
+          )
+        }
+        const active = await serverSDK.api.session.active()
+        seedActiveSessionStatuses(session, active)
+        for (const sessionID of Object.keys(active)) {
+          void session.resolve(sessionID).catch(() => undefined)
+        }
+        return active
+      },
+    }),
+  )
 
   const [globalStore, setGlobalStore] = createStore<GlobalStore>({
     get ready() {
@@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     queryFn: async () => {
       await bootstrapGlobal({
         serverSDK: serverSDK.client,
+        serverAPI: serverSDK.api,
+        protocol: serverSDK.protocol,
         scope: serverSDK.scope,
         requestFailedTitle: language.t("common.requestFailed"),
         translate: language.t,
@@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     bootstrapInstance,
   })
 
-  const session = createServerSession(serverSDK.client)
-
   const children = createChildStoreManager({
     owner,
     scope: serverSDK.scope,
@@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       void bootstrapInstance(directory)
     },
     onMcp: (directory, setStore) => {
-      void retry(() =>
-        sdkFor(directory)
-          .command.list()
-          .then((x) => setStore("command", x.data ?? [])),
-      ).catch((err) => {
-        showToast({
-          variant: "error",
-          title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
-          description: formatServerError(err, language.t),
+      void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
+        .then((commands) => setStore("command", commands))
+        .catch((err) => {
+          showToast({
+            variant: "error",
+            title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
+            description: formatServerError(err, language.t),
+          })
         })
-      })
     },
     onDispose: (directory) => {
       const key = directoryKey(directory)
@@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       .fetchQuery({
         ...queryOptionsApi.sessions(key),
         queryFn: () =>
-          loadRootSessionsWithFallback({
-            directory,
-            limit,
-            list: (query) => serverSDK.client.session.list(query),
-          })
+          serverSDK.protocol
+            .then((protocol) =>
+              protocol === "v1"
+                ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
+                : loadRootSessions({ api: serverSDK.api.session, directory, limit }),
+            )
             .then((x) => {
               const nonArchived = (x.data ?? [])
                 .filter((s) => !!s?.id)
@@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
           provider: globalStore.provider,
         },
         sdk,
+        api: serverSDK.api,
         store: child[0],
         setStore: child[1],
         vcsCache: cache,
@@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
         translate: language.t,
         queryClient,
         session,
+        protocol: serverSDK.protocol,
       })
     })
 
@@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     return promise
   }
 
+  const indexSession = (info: Parameters<typeof session.remember>[0]) => {
+    const key = directoryKey(info.directory)
+    const existing = children.children[key]
+    if (!existing) return
+    applyDirectoryEvent({
+      event: { type: "session.created", properties: { info } },
+      directory: key,
+      store: existing[0],
+      setStore: existing[1],
+      push: queue.push,
+      retainedLimit: sessionMeta.get(key)?.limit,
+      sessionContent: false,
+      permission: session.data.permission,
+      loadLsp() {},
+    })
+  }
+
   const unsub = serverSDK.event.listen((e) => {
     const directory = e.name
     const key = directoryKey(directory)
     const event = e.details
+    const eventType: string = event.type
     const recent = bootingRoot || Date.now() - bootedAt < 1500
 
+    if (event.current) session.applyV2(event.current)
     session.apply(event)
     if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
       homeSessions.apply(event)
@@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     homeSessions.refresh(event.type)
 
     if (directory === "global") {
+      if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
+        void activeSessionsQuery.refetch()
       applyGlobalEvent({
         event,
         project: globalStore.project,
@@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
         },
         setGlobalProject: setProjects,
       })
-      if (event.type === "server.connected" || event.type === "global.disposed") {
+      if (
+        eventType === "config.updated" ||
+        eventType === "catalog.updated" ||
+        eventType === "agent.updated" ||
+        eventType === "project.directories.updated"
+      )
+        bootstrap.refetch()
+      if (eventType === "server.connected" || eventType === "global.disposed") {
         if (recent) return
         for (const directory of Object.keys(children.children)) {
           if (!children.active(directory)) continue
@@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       return
     }
 
+    if (event.current?.type === "session.moved") {
+      const info = session.get(event.current.data.sessionID)
+      if (info) indexSession(info)
+    }
+    if (event.current?.type === "session.forked")
+      void session
+        .resolve(event.current.data.sessionID, { force: true })
+        .then(indexSession)
+        .catch(() => {})
+
     const existing = children.children[key]
     if (!existing) return
     children.mark(key)
+    if (
+      event.current?.type === "session.moved" ||
+      event.current?.type === "session.archived" ||
+      event.current?.type === "session.forked" ||
+      eventType === "command.updated" ||
+      eventType === "config.updated" ||
+      eventType === "agent.updated"
+    )
+      queue.push(key)
+    if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
+    if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
     const [store, setStore] = existing
     applyDirectoryEvent({
       event,
@@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       toggle: async (directory: string, name: string) => {
         const key = directoryKey(directory)
         const sdk = sdkFor(key)
-        const status = children.child(key, { bootstrap: false })[0].mcp[name].status
+        const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
+        if (!status) return
         await toggleMcp({
           status,
           connect: async () => {
-            await sdk.mcp.connect({ name })
+            if ((await serverSDK.protocol) === "v1") {
+              await sdk.mcp.connect({ name })
+              return
+            }
+            await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
           },
           disconnect: async () => {
-            await sdk.mcp.disconnect({ name })
+            if ((await serverSDK.protocol) === "v1") {
+              await sdk.mcp.disconnect({ name })
+              return
+            }
+            await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
           },
           authenticate: async () => {
             await sdk.mcp.auth.authenticate({ name })

+ 7 - 0
packages/app/src/context/settings.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import {
+  hasExistingWebState,
   isAppUpgrade,
   layoutTransitionState,
   maximumSunsetTimeout,
@@ -23,6 +24,12 @@ describe("layout transition", () => {
     expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
   })
 
+  test("classifies web profiles from existing settings or a recorded version", () => {
+    expect(hasExistingWebState("{}", undefined)).toBe(true)
+    expect(hasExistingWebState(null, "1.17.19")).toBe(true)
+    expect(hasExistingWebState(null, undefined)).toBe(false)
+  })
+
   test("preserves explicit and default layout preferences", () => {
     expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
     expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)

+ 11 - 1
packages/app/src/context/settings.tsx

@@ -89,6 +89,10 @@ export function shouldDisplayTabsToast(
   return isAppUpgrade(previous, current) || (!previous && existingInstall)
 }
 
+export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
+  return settings !== null || previousVersion !== undefined
+}
+
 export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
   if (!current) return false
   const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
@@ -220,7 +224,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
   gate: false,
   init: () => {
     const platform = usePlatform()
-    const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
+    const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
     const [launch, setLaunch, , launchReady] = persisted(
       "app-version.v1",
       createStore<{ version?: string }>({ version: undefined }),
@@ -293,6 +297,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
       setLaunch("version", platform.version)
     })
 
+    createEffect(() => {
+      if (!ready() || !launchState.classified || platform.platform !== "web") return
+      if (layoutTransitionClassified()) return
+      setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
+    })
+
     createEffect(() => {
       if (!ready() || !launchState.classified || launchState.migrationApplied) return
       if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {

+ 63 - 38
packages/app/src/context/terminal.tsx

@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
   scope: ServerScopeValue,
   legacySessionID?: string,
 ) {
+  const location = { directory: sdk.directory }
   const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
 
   const [store, setStore, _, ready] = persisted(
@@ -240,47 +241,63 @@ function createWorkspaceTerminalSession(
   })
   onCleanup(unsub)
 
-  const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
+  const update = (pty: Partial<LocalPTY> & { id: string }) => {
     const index = store.all.findIndex((x) => x.id === pty.id)
     const previous = index >= 0 ? store.all[index] : undefined
     if (index >= 0) {
       setStore("all", index, (item) => ({ ...item, ...pty }))
     }
-    client.pty
-      .update({
-        ptyID: pty.id,
-        title: pty.title,
-        size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-      })
-      .catch((error: unknown) => {
-        if (previous) {
-          const currentIndex = store.all.findIndex((item) => item.id === pty.id)
-          if (currentIndex >= 0) setStore("all", currentIndex, previous)
-        }
-        console.error("Failed to update terminal", error)
-      })
+    const doUpdate = async () => {
+      if ((await sdk.protocol) === "v1") {
+        await sdk.client.pty.update({
+          ptyID: pty.id,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+      } else {
+        await sdk.api.pty.update({
+          ptyID: pty.id,
+          location,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+      }
+    }
+    doUpdate().catch((error: unknown) => {
+      if (previous) {
+        const currentIndex = store.all.findIndex((item) => item.id === pty.id)
+        if (currentIndex >= 0) setStore("all", currentIndex, previous)
+      }
+      console.error("Failed to update terminal", error)
+    })
   }
 
-  const clone = async (client: DirectorySDK["client"], id: string) => {
+  const clone = async (id: string) => {
     const index = store.all.findIndex((x) => x.id === id)
     const pty = store.all[index]
     if (!pty) return
-    const next = await client.pty
-      .create({
-        title: pty.title,
-      })
-      .catch((error: unknown) => {
-        console.error("Failed to clone terminal", error)
-        return undefined
-      })
-    if (!next?.data) return
+    const data = await (async () => {
+      if ((await sdk.protocol) === "v1") {
+        return (await sdk.client.pty.create({ title: pty.title })).data
+      }
+      return (
+        await sdk.api.pty.create({
+          location,
+          title: pty.title,
+        })
+      ).data
+    })().catch((error: unknown) => {
+      console.error("Failed to clone terminal", error)
+      return undefined
+    })
+    if (!data?.id) return
 
     const active = store.active === pty.id
 
     batch(() => {
       setStore("all", index, {
-        id: next.data.id,
-        title: next.data.title ?? pty.title,
+        id: data.id,
+        title: data.title ?? pty.title,
         titleNumber: pty.titleNumber,
         buffer: undefined,
         cursor: undefined,
@@ -289,7 +306,7 @@ function createWorkspaceTerminalSession(
         cols: undefined,
       })
       if (active) {
-        setStore("active", next.data.id)
+        setStore("active", data.id)
       }
     })
   }
@@ -308,17 +325,22 @@ function createWorkspaceTerminalSession(
       const nextNumber = pickNextTerminalNumber()
       const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
 
-      sdk.client.pty
-        .create({ title: defaultTitle(nextNumber) })
-        .then((pty: { data?: { id?: string; title?: string } }) => {
-          const id = pty.data?.id
+      const doCreate = async () => {
+        if ((await sdk.protocol) === "v1") {
+          return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
+        }
+        return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
+      }
+      doCreate()
+        .then((data) => {
+          const id = data?.id
           if (!id) {
             if (focusRequest !== undefined) cancelFocus(focusRequest)
             return
           }
           const newTerminal = {
             id,
-            title: pty.data?.title ?? defaultTitle(nextNumber),
+            title: data?.title ?? defaultTitle(nextNumber),
             titleNumber: nextNumber,
           }
           batch(() => {
@@ -335,7 +357,7 @@ function createWorkspaceTerminalSession(
         })
     },
     update(pty: Partial<LocalPTY> & { id: string }) {
-      update(sdk.client, pty)
+      update(pty)
     },
     trim(id: string) {
       const index = store.all.findIndex((x) => x.id === id)
@@ -350,10 +372,9 @@ function createWorkspaceTerminalSession(
       })
     },
     async clone(id: string) {
-      await clone(sdk.client, id)
+      await clone(id)
     },
     bind() {
-      const client = sdk.client
       return {
         trim(id: string) {
           const index = store.all.findIndex((x) => x.id === id)
@@ -361,10 +382,10 @@ function createWorkspaceTerminalSession(
           setStore("all", index, (pty) => trimTerminal(pty))
         },
         update(pty: Partial<LocalPTY> & { id: string }) {
-          update(client, pty)
+          update(pty)
         },
         async clone(id: string) {
-          await clone(client, id)
+          await clone(id)
         },
       }
     },
@@ -412,7 +433,11 @@ function createWorkspaceTerminalSession(
         })
       }
 
-      await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
+      const removePromise =
+        (await sdk.protocol) === "v1"
+          ? sdk.client.pty.remove({ ptyID: id })
+          : sdk.api.pty.remove({ ptyID: id, location })
+      await removePromise.catch((error: unknown) => {
         console.error("Failed to close terminal", error)
       })
     },

+ 2 - 2
packages/app/src/pages/home-session-archive.test.ts

@@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
   await archiveHomeSession({
     server: remote,
     session: { id: "ses_1", directory: "/workspace" },
-    update: async () => undefined,
+    archive: async () => undefined,
     remove: () => {
       removed = true
     },
@@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
   await archiveHomeSession({
     server: remote,
     session: { id: "ses_1", directory: "/workspace" },
-    update: async () => Promise.reject(failure),
+    archive: async () => Promise.reject(failure),
     remove: () => {
       removed = true
     },

+ 2 - 12
packages/app/src/pages/home-session-archive.ts

@@ -6,25 +6,15 @@ type HomeSession = {
   directory: string
 }
 
-type SessionUpdate = {
-  directory: string
-  sessionID: string
-  time: { archived: number }
-}
-
 export async function archiveHomeSession(input: {
   server: ServerConnection.Key
   session: HomeSession
-  update: (value: SessionUpdate) => Promise<unknown>
+  archive: (sessionID: string) => Promise<unknown>
   remove: () => void
   onError?: (error: unknown) => void
 }) {
   await input
-    .update({
-      directory: input.session.directory,
-      sessionID: input.session.id,
-      time: { archived: Date.now() },
-    })
+    .archive(input.session.id)
     .then(() => {
       input.remove()
       notifySessionTabsRemoved({

+ 35 - 1911
packages/app/src/pages/home.tsx

@@ -1,1926 +1,50 @@
-import type { Session } from "@opencode-ai/sdk/v2/client"
-import {
-  type ComponentProps,
-  createEffect,
-  createMemo,
-  createResource,
-  createRoot,
-  createSignal,
-  For,
-  Match,
-  on,
-  onCleanup,
-  onMount,
-  Show,
-  startTransition,
-  Switch,
-} from "solid-js"
-import { makeEventListener } from "@solid-primitives/event-listener"
-import { createStore, produce } from "solid-js/store"
-import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
-import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
-import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
-import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
-import { RestrictToElement } from "@dnd-kit/dom/modifiers"
-import { useQuery } from "@tanstack/solid-query"
-import { Button } from "@opencode-ai/ui/button"
-import { Logo } from "@opencode-ai/ui/logo"
-import { Spinner } from "@opencode-ai/ui/spinner"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
-import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
-import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
-import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
-import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
-import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
-import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout"
-import { useNavigate } from "@solidjs/router"
-import { base64Encode } from "@opencode-ai/core/util/encode"
-import { Icon } from "@opencode-ai/ui/icon"
-import { usePlatform } from "@/context/platform"
-import { DateTime } from "luxon"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useDirectoryPicker } from "@/components/directory-picker"
-import { useSettingsCommand } from "@/components/settings-dialog"
-import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
-import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
-import { ServerConnection, serverName, useServer } from "@/context/server"
-import { sessionHasOpenTab, useTabs } from "@/context/tabs"
-import { useServerSync } from "@/context/server-sync"
-import { useLanguage } from "@/context/language"
-import { useNotification } from "@/context/notification"
-import {
-  closeHomeProject,
-  displayName,
-  errorMessage,
-  getProjectAvatarSource,
-  homeProjectDirectories,
-  projectForSession,
-  toggleHomeProjectSelection,
-} from "@/pages/layout/helpers"
-import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
-import { sessionTitle } from "@/utils/session-title"
-import { pathKey } from "@/utils/path-key"
-import { useGlobal } from "@/context/global"
-import { useCommand } from "@/context/command"
-import { Binary } from "@opencode-ai/core/util/binary"
-import { ServerRowMenu } from "@/components/server/server-row-menu"
-import { ServerHealthIndicator } from "@/components/server/server-row"
-import { type ServerHealth } from "@/utils/server-health"
-import { Persist, persisted } from "@/utils/persist"
-import { useMarked } from "@opencode-ai/ui/context/marked"
-import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
-import { archiveHomeSession } from "./home-session-archive"
-import { shouldOpenSessionInBackground } from "./home-session-open"
-import { showToast } from "@/utils/toast"
-import { fileManagerApp } from "@/utils/file-manager"
-import {
-  loadHomeSessionIndex,
-  retainHomeSessions,
-  type HomeSessionEvents,
-} from "@/context/global-sync/home-session-index"
-
-const HOME_SESSION_LIMIT = 64
-const HOME_SESSION_HEADER_STICKY_TOP = 12
-const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
-const HOME_SESSION_HEADER_FADE_DISTANCE = 16
-
-function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
-  if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
-  if (!(event.target instanceof Element)) return
-
-  const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
-  if (
-    scrollable !== viewport &&
-    scrollable &&
-    (event.deltaY < 0
-      ? scrollable.scrollTop > 0
-      : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
-  )
-    return
-
-  event.preventDefault()
-}
-const SHOW_HOME_SESSION_ARCHIVE = false
-const HOME_ROW_LAYOUT =
-  "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
-const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
-const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
-const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
-const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
-const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
-
-type HomeSessionRecord = {
-  session: Session
-  project: LocalProject
-  projectName: string
-}
-
-type HomeSessionGroup = {
-  id: "today" | "yesterday" | "older"
-  title: string
-  sessions: HomeSessionRecord[]
-}
-
-const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
-const HOME_SEARCH_RESULT_ROW =
-  "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
-const HOME_SEARCH_RESULT_TITLE =
-  "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
-const HOME_SEARCH_RESULT_META =
-  "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
-
-let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
-
-function buildHomeSessionRecords(input: {
-  sessions: () => Session[]
-  projectDirectories: () => string[]
-  projects: () => LocalProject[]
-  projectByID: () => Map<string, LocalProject>
-}) {
-  const directories = new Set(input.projectDirectories().map(pathKey))
-  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
-  return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
-    .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
-    .flatMap((session) => {
-      const directory = pathKey(session.directory)
-      const project =
-        input
-          .projects()
-          .find(
-            (item) =>
-              pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
-          ) ?? projectForSession(session, input.projects(), input.projectByID())
-      if (!project) return []
-      return {
-        session,
-        project,
-        projectName: displayName(project),
-      }
-    })
-}
-
-function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
-  return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
-}
-
-function homeSessionSearchKey(record: HomeSessionRecord) {
-  return `${pathKey(record.session.directory)}:${record.session.id}`
-}
-
-function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
-  let viewport: HTMLDivElement | undefined
-  let content: HTMLDivElement | undefined
-  let positionFrame: number | undefined
-  let resizeObserver: ResizeObserver | undefined
-  let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
-  const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
-  const headerOffsets = new Map<HomeSessionGroup["id"], number>()
-  const [state, setState] = createStore({
-    titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
-  })
-
-  createEffect(() => {
-    const items = groups()
-    const ids = new Set(items.map((group) => group.id))
-    headerRefs.forEach((_, id) => {
-      if (!ids.has(id)) headerRefs.delete(id)
-    })
-    headerOffsets.forEach((_, id) => {
-      if (!ids.has(id)) headerOffsets.delete(id)
-    })
-    if (items.length === 0) {
-      content = undefined
-      bindResizeObserver()
-    }
-    queuePositionUpdate()
-  })
-
-  onCleanup(() => {
-    if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
-    resizeObserver?.disconnect()
-  })
-
-  function setViewport(el: HTMLDivElement) {
-    viewport = el
-    bindResizeObserver()
-    queuePositionUpdate()
-  }
-
-  function setContentRef(el: HTMLDivElement) {
-    content = el
-    bindResizeObserver()
-    queuePositionUpdate()
-  }
-
-  function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
-    headerRefs.set(id, el)
-    queuePositionUpdate()
-  }
-
-  function queuePositionUpdate() {
-    if (typeof requestAnimationFrame === "undefined") {
-      updatePositionCache()
-      return
-    }
-    if (positionFrame !== undefined) return
-    positionFrame = requestAnimationFrame(() => {
-      positionFrame = undefined
-      updatePositionCache()
-    })
-  }
-
-  function updatePositionCache() {
-    if (!viewport) return
-    const header = groups()
-      .map((group) => headerRefs.get(group.id))
-      .find((el) => el !== undefined)
-    if (header && typeof getComputedStyle === "function") {
-      const top = Number.parseFloat(getComputedStyle(header).top)
-      if (Number.isFinite(top)) stickyTop = top
-    }
-    groups().forEach((group) => {
-      const el = headerRefs.get(group.id)
-      if (!el) return
-      headerOffsets.set(group.id, el.offsetTop)
-    })
-    update(viewport.scrollTop)
-  }
-
-  function update(scrollTop: number) {
-    const items = groups()
-    items.forEach((group, index) => {
-      const nextOffset = items
-        .slice(index + 1)
-        .map((item) => headerOffsets.get(item.id))
-        .find((offset) => offset !== undefined)
-      const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
-      const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
-      const opacity =
-        nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
-      setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
-    })
-  }
-
-  function titleOpacity(id: HomeSessionGroup["id"]) {
-    return state.titleOpacity[id] ?? 1
-  }
-
-  function bindResizeObserver() {
-    resizeObserver?.disconnect()
-    if (typeof ResizeObserver === "undefined") return
-    resizeObserver = new ResizeObserver(() => queuePositionUpdate())
-    if (viewport) resizeObserver.observe(viewport)
-    if (content) resizeObserver.observe(content)
-  }
-
-  return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
-}
-
-// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
-// tab in the background without navigating, matching browser conventions.
-function isBackgroundOpen(event: MouseEvent) {
-  return shouldOpenSessionInBackground({
-    button: event.button,
-    mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
-    meta: event.metaKey,
-    ctrl: event.ctrlKey,
-    shift: event.shiftKey,
-    alt: event.altKey,
-  })
-}
-
-type OpenSessionOptions = { background?: boolean }
+import { createHomeController } from "./home/home-controller"
+import { createHomeProjectsController } from "./home/home-projects-controller"
+import { HomeUtilityNav } from "./home/home-projects-view"
+import { HomeProjects } from "./home/home-projects"
+import { createHomeScrollController } from "./home/home-scroll-controller"
+import { createHomeSessionSearchController } from "./home/home-session-search-controller"
+import { createHomeSessionsController } from "./home/home-sessions-controller"
+import { HomeSessions } from "./home/home-sessions"
 
 export function NewHome() {
-  const sync = useServerSync()
-  const layout = useLayout()
-  const platform = usePlatform()
-  const pickDirectory = useDirectoryPicker()
-  const dialog = useDialog()
-  const navigate = useNavigate()
-  const server = useServer()
-  const language = useLanguage()
-  const global = useGlobal()
-  const tabs = useTabs()
-  const command = useCommand()
-  const notification = useNotification()
-  const marked = useMarked()
-  const openSettings = useSettingsCommand()
-  let focusSessionSearch: (() => void) | undefined
-  let sessionViewport: HTMLDivElement | undefined
-  const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
-  const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
-  const [state, setState] = createStore({
-    search: "",
-    searchFocused: false,
-  })
-  const selection = layout.home.selection
-
-  const focusedServer = createMemo(
-    () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
-  )
-  const focusedServerCtx = createMemo(() => {
-    const conn = focusedServer()
-    if (!conn) return
-    return global.ensureServerCtx(conn)
-  })
-  const focusedSync = () => focusedServerCtx()?.sync ?? sync()
-  const homeSessions = () => focusedSync().homeSessions
-  const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
-  const recentlyClosed = createMemo(
-    () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
-  )
-  const homedir = createMemo(() => focusedSync().data.path.home ?? "")
-  const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
-  const newSessionProject = createMemo(
-    () =>
-      selectedProject() ??
-      projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
-      projects()[0],
-  )
-  const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
-  const projectDirectories = createMemo(() => {
-    const project = selectedProject()
-    if (!project) return projects().flatMap(directories)
-    return directories(project)
-  })
-  const search = createMemo(() => state.search.trim())
-  const searchPlaceholder = createMemo(() => {
-    const project = selectedProject()
-    if (project) {
-      return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
-    }
-    if (global.servers.list().length > 1) {
-      const conn = focusedServer()
-      if (conn) {
-        return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
-      }
-    }
-    return language.t("home.sessions.search.placeholder")
-  })
-  const sessionEventLoad = useQuery(() => ({
-    queryKey: homeSessions().eventsKey,
-    queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
-    initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
-    enabled: false,
-  }))
-  const sessionLoad = useQuery(() => ({
-    queryKey: homeSessions().indexKey,
-    enabled: !!focusedServerCtx(),
-    queryFn: async ({ signal }) => {
-      const ctx = focusedServerCtx()
-      if (!ctx) return { sessions: [], eventSequence: 0 }
-      const cache = homeSessions()
-      const eventSequence = cache.eventSequence()
-      const index = await loadHomeSessionIndex(
-        (input, options) => ctx.sdk.client.v2.session.list(input, options),
-        eventSequence,
-        signal,
-      )
-      cache.complete(eventSequence)
-      return index
-    },
-    retry: false,
-    staleTime: 30_000,
-    refetchOnMount: true,
-    refetchOnReconnect: true,
-  }))
-
-  const projectByID = createMemo(
-    () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
-  )
-  const indexedSessions = createMemo(() =>
-    retainHomeSessions(
-      homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
-      HOME_SESSION_LIMIT,
-      Date.now(),
-    ),
-  )
-  const allRecords = createMemo(() =>
-    buildHomeSessionRecords({
-      sessions: indexedSessions,
-      projectDirectories,
-      projects,
-      projectByID,
-    }),
-  )
-  const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
-  const searchResults = createMemo(() => {
-    const query = search().toLowerCase()
-    if (!query) return []
-    return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
-  })
-  const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
-  const groups = createMemo(() => groupSessions(records(), language))
-  const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
-  const prefetched = new Set<string>()
-
-  createEffect(() => {
-    const ctx = focusedServerCtx()
-    if (!ctx) return
-    records()
-      .slice(0, 2)
-      .forEach((record) => {
-        const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
-        if (prefetched.has(key)) return
-        prefetched.add(key)
-        createRoot((dispose) => {
-          try {
-            void ctx.sync.session
-              .sync(record.session.id)
-              .then(() => {
-                return Promise.all(
-                  (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
-                    (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
-                      if (part.type !== "text" || !part.text) return []
-                      return preloadMarkdown(part.text, part.id, marked)
-                    }),
-                  ),
-                )
-              })
-              .catch(() => {})
-              .finally(dispose)
-          } catch {
-            dispose()
-          }
-        })
-      })
-  })
-
-  function setSelection(next: HomeProjectSelection) {
-    layout.home.setSelection(next)
-  }
-
-  function closeSearch() {
-    setState("search", "")
-    setState("searchFocused", false)
-  }
-
-  function selectSearchSession(session: Session, options?: OpenSessionOptions) {
-    openSession(session, options)
-    // Background opens keep the search visible so several results can be
-    // opened in a row.
-    if (!options?.background) closeSearch()
-  }
-
-  command.register("home", () => [
-    {
-      id: "command.palette",
-      title: language.t("command.palette"),
-      hidden: true,
-      onSelect: async () => {
-        const conn = focusedServer()
-        if (!conn) return
-        const ctx = global.ensureServerCtx(conn)
-        const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
-        void dialog.show(() => (
-          <DialogHomeCommandPaletteV2
-            server={conn}
-            onSelectSession={(entry) => {
-              if (!entry.sessionID || !entry.directory || !entry.server) return
-              const sessionID = entry.sessionID
-              const server = entry.server
-              const directory = entry.project?.worktree ?? entry.directory
-              ctx.projects.open(directory)
-              ctx.projects.touch(directory)
-              void startTransition(() => {
-                const tab = tabs.addSessionTab({ server, sessionId: sessionID })
-                tabs.select(tab)
-              })
-            }}
-          />
-        ))
-      },
-    },
-    {
-      id: "home.sessions.search.focus",
-      title: searchPlaceholder(),
-      keybind: "mod+f",
-      hidden: true,
-      onSelect: () => focusSessionSearch?.(),
-    },
-  ])
-
-  createEffect(() => {
-    const list = global.servers.list()
-    if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
-    const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
-    if (conn) setSelection({ server: ServerConnection.key(conn) })
-  })
-
-  createEffect(() => {
-    const pending = pendingHomeNavigation
-    if (!pending || pending.server !== server.key) return
-    pendingHomeNavigation = undefined
-    navigate(pending.href)
-  })
-
-  function focusServer(conn: ServerConnection.Any) {
-    setSelection({ server: ServerConnection.key(conn) })
-  }
-
-  function selectProject(conn: ServerConnection.Any, directory: string) {
-    const key = ServerConnection.key(conn)
-    if (global.servers.health[key]?.healthy === false) return
-    if (
-      !global
-        .ensureServerCtx(conn)
-        .projects.list()
-        .some((project) => project.worktree === directory)
-    )
-      return
-    setSelection(toggleHomeProjectSelection(selection(), key, directory))
-  }
-
-  function addProjects(conn: ServerConnection.Any, directories: string[]) {
-    const directory = directories[0]
-    if (!directory) return
-    const ctx = global.ensureServerCtx(conn)
-    directories.forEach(ctx.projects.open)
-    ctx.projects.touch(directory)
-    setSelection({ server: ServerConnection.key(conn), directory })
-  }
-
-  function openNewSession() {
-    const conn = focusedServer()
-    const project = newSessionProject()
-    if (!conn || !project) return
-    openProjectNewSession(conn, project.worktree)
-  }
-
-  function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
-    const ctx = global.ensureServerCtx(conn)
-    ctx.projects.open(directory)
-    ctx.projects.touch(directory)
-    tabs.newDraft({ server: ServerConnection.key(conn), directory })
-  }
-
-  function editProject(conn: ServerConnection.Any, project: LocalProject) {
-    void import("@/components/dialog-edit-project-v2").then((x) => {
-      void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
-    })
-  }
-
-  function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
-    const state = notification.ensureServerState(ServerConnection.key(conn))
-    return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
-  }
-
-  function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
-    const state = notification.ensureServerState(ServerConnection.key(conn))
-    directories(project)
-      .filter((directory) => state.project.unseenCount(directory) > 0)
-      .forEach((directory) => state.project.markViewed(directory))
-  }
-
-  function openSession(session: Session, options?: OpenSessionOptions) {
-    const directoryKey = pathKey(session.directory)
-    const project =
-      projects().find(
-        (item) =>
-          pathKey(item.worktree) === directoryKey ||
-          item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
-      ) ?? projectForSession(session, projects(), projectByID())
-    const conn = focusedServer()
-    if (!conn) return
-    const directory = project?.worktree ?? session.directory
-    const ctx = global.ensureServerCtx(conn)
-    ctx.projects.open(directory)
-    if (options?.background) {
-      tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
-      return
-    }
-    ctx.projects.touch(directory)
-    startTransition(() => {
-      const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
-      tabs.select(tab)
-    })
-  }
-
-  async function archiveSession(session: Session) {
-    const conn = focusedServer()
-    const ctx = focusedServerCtx()
-    if (!conn || !ctx) return
-    const [, setStore] = ctx.sync.child(session.directory)
-    await archiveHomeSession({
-      server: ServerConnection.key(conn),
-      session,
-      update: (value) => ctx.sdk.client.session.update(value),
-      remove: () =>
-        setStore(
-          produce((draft) => {
-            const match = Binary.search(draft.session, session.id, (s) => s.id)
-            if (match.found) draft.session.splice(match.index, 1)
-          }),
-        ),
-      onError: (error) =>
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(error, language.t("common.requestFailed")),
-        }),
-    })
-  }
-
-  function chooseProject(conn: ServerConnection.Any) {
-    if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
-
-    function resolve(result: string | string[] | null) {
-      addProjects(conn, homeProjectDirectories(result))
-    }
-
-    pickDirectory({
-      server: conn,
-      title: language.t("command.project.open"),
-      multiple: true,
-      onSelect: resolve,
-    })
-  }
-
+  const home = createHomeController()
+  const projects = createHomeProjectsController(home)
+  const sessions = createHomeSessionsController(home)
+  const search = createHomeSessionSearchController(home, sessions)
+  const scroll = createHomeScrollController(sessions.data.groups)
   return (
-    <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
+    <div
+      class={`
+        m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
+        bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
+      `}
+    >
       <ScrollView
         class="h-full [container-type:size]"
-        thumbContainer={sessionThumbTrack}
-        thumbHoverTarget={sessionHoverTarget}
-        viewportRef={(el) => {
-          sessionViewport = el
-          sessionHeaderOpacity.setViewport(el)
-        }}
-        onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
-        onWheel={(event) => {
-          if (!sessionViewport) return
-          if (event.target instanceof Node && sessionViewport.contains(event.target)) return
-          containHomeWheel(event, sessionViewport)
-        }}
+        thumbContainer={scroll.viewport.thumbTrack}
+        thumbHoverTarget={scroll.viewport.hoverTarget}
+        viewportRef={scroll.viewport.setViewport}
+        onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
+        onWheel={scroll.viewport.containOuterWheel}
       >
-        <div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
-          <HomeProjectColumn
-            projects={projects()}
-            recentlyClosed={recentlyClosed()}
-            homedir={homedir()}
-            selected={selection()}
-            focusServer={focusServer}
-            selectProject={selectProject}
-            openNewSession={openProjectNewSession}
-            openRecentProject={(conn, directory) => addProjects(conn, [directory])}
-            chooseProject={(conn) => void chooseProject(conn)}
-            editProject={editProject}
-            closeProject={(conn, directory) => {
-              const next = closeHomeProject(
-                selection(),
-                ServerConnection.key(conn),
-                global.ensureServerCtx(conn).projects,
-                directory,
-              )
-              if (next) setSelection(next)
-            }}
-            clearNotifications={clearNotifications}
-            unseenCount={unseenCount}
-            openSettings={openSettings}
-            openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-            language={language}
-            onWheel={(event) => {
-              if (sessionViewport) containHomeWheel(event, sessionViewport)
-            }}
-          />
-
-          <section
-            ref={setSessionHoverTarget}
-            class="min-h-0 min-w-0 flex-1 flex flex-col"
-            aria-label={language.t("sidebar.project.recentSessions")}
-          >
-            <div
-              class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
-              onWheel={(event) => {
-                if (sessionViewport) containHomeWheel(event, sessionViewport)
-              }}
-            >
-              <HomeSessionSearch
-                value={state.search}
-                placeholder={searchPlaceholder()}
-                open={searchOpen()}
-                loading={sessionLoad.isLoading}
-                results={searchResults()}
-                showProjectName={!selectedProject()}
-                server={selection().server}
-                noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
-                bindFocus={(focus) => {
-                  focusSessionSearch = focus
-                }}
-                onInput={(value) => setState("search", value)}
-                onFocus={() => setState("searchFocused", true)}
-                onClose={closeSearch}
-                onSelect={selectSearchSession}
-              />
-              <Show when={groups().length > 0 && newSessionProject()}>
-                <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
-                  <ButtonV2
-                    data-action="home-new-session"
-                    variant="ghost-muted"
-                    size="normal"
-                    icon="edit"
-                    class="pointer-events-auto h-7 px-2 [font-weight:530]"
-                    onClick={openNewSession}
-                  >
-                    {language.t("command.session.new")}
-                  </ButtonV2>
-                </div>
-              </Show>
-            </div>
-            {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
-            <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
-              <div
-                ref={setSessionThumbTrack}
-                data-component="home-session-scroll-track"
-                class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
-              />
-            </div>
-            <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
-              <Show
-                when={!sessionLoad.isLoading}
-                fallback={
-                  <div class="pt-3">
-                    <HomeSessionSkeleton label={language.t("common.loading")} />
-                  </div>
-                }
-              >
-                <Show
-                  when={groups().length > 0}
-                  fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
-                >
-                  <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
-                    <For each={groups()}>
-                      {(group, index) => (
-                        <>
-                          <HomeSessionGroupHeader
-                            title={group.title}
-                            titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
-                            ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
-                            elevated={index() === 0}
-                          />
-                          <div
-                            class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
-                          >
-                            <For each={group.sessions}>
-                              {(record) => (
-                                <HomeSessionRow
-                                  record={record}
-                                  showProjectName={!selectedProject()}
-                                  server={selection().server}
-                                  openSession={openSession}
-                                  archiveSession={archiveSession}
-                                />
-                              )}
-                            </For>
-                          </div>
-                        </>
-                      )}
-                    </For>
-                  </div>
-                </Show>
-              </Show>
-            </div>
-          </section>
+        <div
+          class={`
+            mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
+            lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
+          `}
+        >
+          <HomeProjects projects={projects} scroll={scroll} />
+          <HomeSessions sessions={sessions} search={search} scroll={scroll} />
           <HomeUtilityNav
             class="flex lg:hidden"
-            openSettings={openSettings}
-            openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-            language={language}
+            onOpenSettings={projects.utility.settings}
+            onOpenHelp={projects.utility.help}
+            language={projects.copy.language}
           />
         </div>
       </ScrollView>
     </div>
   )
 }
-
-function HomeProjectColumn(props: {
-  projects: LocalProject[]
-  recentlyClosed: LocalProject[]
-  homedir: string
-  selected: HomeProjectSelection
-  focusServer: (server: ServerConnection.Any) => void
-  selectProject: (server: ServerConnection.Any, directory: string) => void
-  openNewSession: (server: ServerConnection.Any, directory: string) => void
-  openRecentProject: (server: ServerConnection.Any, directory: string) => void
-  chooseProject: (server: ServerConnection.Any) => void
-  editProject: (server: ServerConnection.Any, project: LocalProject) => void
-  closeProject: (server: ServerConnection.Any, directory: string) => void
-  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
-  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
-  openSettings: () => void
-  openHelp: () => void
-  language: ReturnType<typeof useLanguage>
-  onWheel: (event: WheelEvent) => void
-}) {
-  const global = useGlobal()
-  const dialog = useDialog()
-  const controller = useServerManagementController({ navigateOnAdd: false })
-  const [_state, setState, _, ready] = persisted(
-    Persist.global("home.servers", ["home.servers.v1"]),
-    createStore({ collapsed: {} as Record<string, boolean> }),
-  )
-  const [state] = createResource(
-    () => ready.promise ?? Promise.resolve(),
-    (p) => p.then(() => _state),
-    { initialValue: _state },
-  )
-
-  return (
-    <aside
-      class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
-      aria-label={props.language.t("home.projects")}
-      onWheel={(event) => {
-        if (event.target === event.currentTarget) return
-        props.onWheel(event)
-      }}
-    >
-      <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
-        <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
-        <Show
-          when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
-        >
-          <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
-            <IconButtonV2
-              data-action="home-add-project"
-              variant="ghost-muted"
-              size="large"
-              class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
-              icon={<IconV2 name="folder-add-left" />}
-              disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
-              onClick={() => props.chooseProject(global.servers.list()[0]!)}
-              aria-label={props.language.t("home.project.add")}
-            />
-          </TooltipV2>
-        </Show>
-      </div>
-      <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
-        <Show
-          when={global.servers.list().length > 1}
-          fallback={
-            <div class="pr-3">
-              <Show
-                when={props.projects.length > 0}
-                fallback={
-                  <HomeProjectEmpty
-                    server={global.servers.list()[0]!}
-                    recentlyClosed={props.recentlyClosed}
-                    homedir={props.homedir}
-                    chooseProject={props.chooseProject}
-                    openRecentProject={props.openRecentProject}
-                    language={props.language}
-                  />
-                }
-              >
-                <HomeProjectList {...props} server={global.servers.list()[0]!} />
-              </Show>
-            </div>
-          }
-        >
-          <div class="flex min-w-0 flex-col gap-4 pr-3">
-            <For each={global.servers.list()}>
-              {(item) => {
-                const key = ServerConnection.key(item)
-                const healthy = () => !!global.servers.health[key]?.healthy
-                const serverCtx = global.ensureServerCtx(item)
-                const projects = () => serverCtx.projects.list()
-                const hasProjects = () => projects().length > 0
-                const collapsed = () => !!state().collapsed[key]
-                return (
-                  <div class="flex min-w-0 flex-col gap-1">
-                    <HomeServerRow
-                      server={item}
-                      selected={props.selected.server === key && !props.selected.directory}
-                      collapsed={collapsed()}
-                      health={global.servers.health[key]}
-                      controller={controller}
-                      focusServer={props.focusServer}
-                      chooseProject={props.chooseProject}
-                      openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
-                      toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
-                      language={props.language}
-                    />
-                    <Show when={healthy() && hasProjects() && !collapsed()}>
-                      <div class="mx-3 h-px bg-v2-border-border-base" />
-                      <HomeProjectList {...props} server={item} projects={projects()} />
-                    </Show>
-                  </div>
-                )
-              }}
-            </For>
-          </div>
-        </Show>
-      </ScrollView>
-      <HomeUtilityNav
-        class="mb-8 mt-4 hidden shrink-0 lg:flex"
-        openSettings={props.openSettings}
-        openHelp={props.openHelp}
-        language={props.language}
-      />
-    </aside>
-  )
-}
-
-function HomeUtilityNav(props: {
-  class?: string
-  openSettings: () => void
-  openHelp: () => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  return (
-    <div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
-      <button
-        type="button"
-        class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-        onClick={props.openSettings}
-      >
-        <IconV2 name="settings-gear" size="small" />
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
-      </button>
-      <button
-        type="button"
-        class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-        onClick={props.openHelp}
-      >
-        <IconV2 name="help" size="small" />
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
-      </button>
-    </div>
-  )
-}
-
-function HomeServerRow(props: {
-  server: ServerConnection.Any
-  selected: boolean
-  collapsed: boolean
-  health: ServerHealth | undefined
-  controller: ReturnType<typeof useServerManagementController>
-  focusServer: (server: ServerConnection.Any) => void
-  chooseProject: (server: ServerConnection.Any) => void
-  openEdit: (server: ServerConnection.Http) => void
-  toggleCollapsed: () => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const [state, setState] = createStore({ menuOpen: false })
-  const healthy = () => !!props.health?.healthy
-  const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
-  return (
-    <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
-      <button
-        type="button"
-        class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
-        data-selected={props.selected ? "" : undefined}
-        disabled={!healthy()}
-        onClick={() => props.focusServer(props.server)}
-      >
-        <span
-          data-action="home-server-collapse"
-          class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
-          classList={{
-            "hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
-            "cursor-default opacity-40": !canToggle(),
-          }}
-          aria-label={
-            props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
-          }
-          aria-disabled={!canToggle()}
-          aria-expanded={canToggle() ? !props.collapsed : undefined}
-          onClick={(event) => {
-            event.preventDefault()
-            event.stopPropagation()
-            if (!canToggle()) return
-            props.toggleCollapsed()
-          }}
-          onPointerDown={(event) => event.preventDefault()}
-        >
-          <IconV2
-            name="chevron-down"
-            size="small"
-            class="transition-transform duration-150 ease-in-out"
-            style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
-          />
-        </span>
-        <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
-          <ServerHealthIndicator health={props.health} />
-        </div>
-        <span class="flex min-w-0 items-center gap-1">
-          <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
-          <Show when={props.server.label}>
-            {(label) => (
-              <span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
-                {label()}
-              </span>
-            )}
-          </Show>
-        </span>
-      </button>
-      <div
-        class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
-        data-menu={state.menuOpen}
-      >
-        <ServerRowMenu
-          server={props.server}
-          controller={props.controller}
-          onEdit={props.openEdit}
-          open={state.menuOpen}
-          onOpenChange={(open) => setState("menuOpen", open)}
-        />
-        <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
-          <IconButtonV2
-            data-action="home-add-project"
-            variant="ghost-muted"
-            size="small"
-            icon={<IconV2 name="folder-add-left" />}
-            aria-label={props.language.t("home.project.add")}
-            disabled={props.health?.healthy === false}
-            onClick={() => props.chooseProject(props.server)}
-          />
-        </TooltipV2>
-      </div>
-    </div>
-  )
-}
-
-type HomeProjectListProps = {
-  server: ServerConnection.Any
-  projects: LocalProject[]
-  selected: HomeProjectSelection
-  selectProject: (server: ServerConnection.Any, directory: string) => void
-  openNewSession: (server: ServerConnection.Any, directory: string) => void
-  editProject: (server: ServerConnection.Any, project: LocalProject) => void
-  closeProject: (server: ServerConnection.Any, directory: string) => void
-  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
-  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
-  language: ReturnType<typeof useLanguage>
-}
-
-function HomeProjectList(props: HomeProjectListProps) {
-  const global = useGlobal()
-  let listRef!: HTMLDivElement
-  const projects = () => global.ensureServerCtx(props.server).projects
-
-  return (
-    <DragDropProvider
-      sensors={(defaults) => [
-        ...defaults.filter((sensor) => sensor !== PointerSensor),
-        PointerSensor.configure({
-          activationConstraints: (event) =>
-            event.pointerType === "touch"
-              ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
-              : [new PointerActivationConstraints.Distance({ value: 4 })],
-          preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
-        }),
-      ]}
-      modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
-      plugins={(defaults) => [
-        ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
-        AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
-        Feedback.configure({ dropAnimation: null }),
-      ]}
-      onDragEnd={(event) => {
-        const source = event.operation.source
-        if (event.canceled || !isSortable(source)) return
-        if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
-        if (props.selected.server !== ServerConnection.key(props.server))
-          props.selectProject(props.server, source.id.toString())
-      }}
-    >
-      <div class="flex min-w-0 flex-col gap-1" ref={listRef}>
-        {/* Keyed on worktree strings: the enriched project objects are
-            recreated on every store or sync update, so iterating them directly
-            remounts all rows — killing any in-flight drag activation (the
-            row's sortable unregisters on unmount) and discarding animations.
-            String keys keep row elements alive and move them on reorder. */}
-        <For each={props.projects.map((project) => project.worktree)}>
-          {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
-        </For>
-      </div>
-    </DragDropProvider>
-  )
-}
-
-function HomeProjectSlot(
-  props: HomeProjectListProps & {
-    worktree: string
-    index: () => number
-  },
-) {
-  const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
-
-  return (
-    <Show when={project()}>
-      {(item) => (
-        <HomeProjectRow
-          project={item()}
-          server={props.server}
-          index={props.index}
-          serverSelected={props.selected.server === ServerConnection.key(props.server)}
-          selected={
-            props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
-          }
-          unseenCount={props.unseenCount(props.server, item())}
-          selectProject={props.selectProject}
-          openNewSession={props.openNewSession}
-          editProject={props.editProject}
-          closeProject={props.closeProject}
-          clearNotifications={props.clearNotifications}
-          language={props.language}
-        />
-      )}
-    </Show>
-  )
-}
-
-function HomeProjectEmpty(props: {
-  server: ServerConnection.Any
-  recentlyClosed: LocalProject[]
-  homedir: string
-  chooseProject: (server: ServerConnection.Any) => void
-  openRecentProject: (server: ServerConnection.Any, directory: string) => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
-  return (
-    <div class="flex min-w-0 flex-col gap-1">
-      <button
-        type="button"
-        data-action="home-add-project-row"
-        class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-        disabled={unreachable()}
-        onClick={() => props.chooseProject(props.server)}
-      >
-        <IconV2 name="folder-add-left" size="small" />
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
-      </button>
-      <Show when={props.recentlyClosed.length > 0}>
-        <div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
-          <div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
-        </div>
-        <For each={props.recentlyClosed}>
-          {(project) => (
-            <HomeRecentlyClosedRow
-              project={project}
-              server={props.server}
-              homedir={props.homedir}
-              openRecentProject={props.openRecentProject}
-              language={props.language}
-            />
-          )}
-        </For>
-      </Show>
-    </div>
-  )
-}
-
-function HomeRecentlyClosedRow(props: {
-  project: LocalProject
-  server: ServerConnection.Any
-  homedir: string
-  openRecentProject: (server: ServerConnection.Any, directory: string) => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
-  const path = () => {
-    const home = props.homedir
-    const worktree = props.project.worktree
-    if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
-    return worktree
-  }
-  return (
-    <TooltipV2 placement="right" value={path()}>
-      <button
-        type="button"
-        data-component="home-recently-closed-row"
-        class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
-        disabled={unreachable()}
-        onClick={() => props.openRecentProject(props.server, props.project.worktree)}
-      >
-        <HomeProjectAvatar project={props.project} outline />
-        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
-      </button>
-    </TooltipV2>
-  )
-}
-
-function HomeProjectRow(props: {
-  project: LocalProject
-  server: ServerConnection.Any
-  index: () => number
-  serverSelected: boolean
-  selected: boolean
-  unseenCount: number
-  selectProject: (server: ServerConnection.Any, directory: string) => void
-  openNewSession: (server: ServerConnection.Any, directory: string) => void
-  editProject: (server: ServerConnection.Any, project: LocalProject) => void
-  closeProject: (server: ServerConnection.Any, directory: string) => void
-  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const platform = usePlatform()
-  const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
-  const [state, setState] = createStore({ menuOpen: false })
-  const sortable = useSortable({
-    get id() {
-      return props.project.worktree
-    },
-    get index() {
-      return props.index()
-    },
-  })
-  let pointerDownSelected: boolean | undefined
-  const canRevealInFileManager = () =>
-    platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
-  const fileManagerActionLabel = () =>
-    props.language.t(
-      fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel,
-    )
-  const revealInFileManager = () => {
-    if (!platform.openPath) return
-    platform.openPath(props.project.worktree).catch((err: unknown) =>
-      showToast({
-        title: props.language.t("common.requestFailed"),
-        description: errorMessage(err, props.language.t("common.requestFailed")),
-      }),
-    )
-  }
-  return (
-    <div
-      ref={sortable.ref}
-      class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
-      classList={{ "z-10": sortable.isDragSource() }}
-    >
-      <button
-        type="button"
-        data-component="home-project-row"
-        class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
-        classList={{
-          "bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
-            sortable.isDragSource(),
-        }}
-        data-selected={props.selected ? "" : undefined}
-        aria-current={props.selected ? "page" : undefined}
-        disabled={serverUnreachable()}
-        onPointerDown={(event) => {
-          // Same-server mouse selection happens on pointerdown (like tabs),
-          // but only ever selects; selectProject toggles, and deselecting here
-          // would fire on every drag before the threshold is met. Cross-server
-          // selection waits for click so reordering a remote server's projects
-          // does not focus that server and load its session index. Touch is
-          // excluded so flick-scrolling the list cannot select rows.
-          pointerDownSelected = undefined
-          if (event.button !== 0 || event.pointerType === "touch") return
-          if (!props.serverSelected) return
-          pointerDownSelected = props.selected
-          if (!props.selected) props.selectProject(props.server, props.project.worktree)
-        }}
-        onClick={(event) => {
-          // The drag sensor calls preventDefault on post-drag clicks; never
-          // toggle selection as part of a reorder.
-          if (event.defaultPrevented) return
-          // Keyboard activation and touch taps keep the original toggle.
-          if (event.detail === 0 || pointerDownSelected === undefined) {
-            props.selectProject(props.server, props.project.worktree)
-            return
-          }
-          // Mouse: pointerdown already selected unselected rows; a plain click
-          // on an already-selected row toggles it off.
-          if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
-          pointerDownSelected = undefined
-        }}
-      >
-        <HomeProjectAvatar project={props.project} />
-        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
-      </button>
-      <div
-        class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
-        data-menu={state.menuOpen}
-      >
-        <MenuV2
-          gutter={6}
-          modal={false}
-          placement="bottom-end"
-          open={state.menuOpen}
-          onOpenChange={(open) => setState("menuOpen", open)}
-        >
-          <MenuV2.Trigger
-            as={IconButtonV2}
-            data-action="home-project-menu"
-            variant="ghost-muted"
-            size="small"
-            icon={<IconV2 name="outline-dots" />}
-            aria-label={props.language.t("common.moreOptions")}
-          />
-          <MenuV2.Portal>
-            <MenuV2.Content>
-              <MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
-                {props.language.t("command.session.new")}
-              </MenuV2.Item>
-              <MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
-                {props.language.t("dialog.project.edit.title")}
-              </MenuV2.Item>
-              <Show when={canRevealInFileManager()}>
-                <MenuV2.Item onSelect={revealInFileManager}>{fileManagerActionLabel()}</MenuV2.Item>
-              </Show>
-              <MenuV2.Item
-                disabled={props.unseenCount === 0}
-                onSelect={() => props.clearNotifications(props.server, props.project)}
-              >
-                {props.language.t("sidebar.project.clearNotifications")}
-              </MenuV2.Item>
-              <MenuV2.Separator />
-              <MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
-                {props.language.t("common.close")}
-              </MenuV2.Item>
-            </MenuV2.Content>
-          </MenuV2.Portal>
-        </MenuV2>
-        <IconButtonV2
-          data-action="home-project-new-session"
-          variant="ghost-muted"
-          size="small"
-          icon={<IconV2 name="edit" />}
-          aria-label={props.language.t("command.session.new")}
-          onClick={() => props.openNewSession(props.server, props.project.worktree)}
-        />
-      </div>
-    </div>
-  )
-}
-
-function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
-  const name = createMemo(() => displayName(props.project))
-  return (
-    <ProjectAvatar
-      fallback={name()}
-      src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
-      variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
-    />
-  )
-}
-
-function HomeSessionLeading(props: {
-  project: LocalProject
-  session: Session
-  server: ServerConnection.Key
-  revealProjectOnHover: boolean
-}) {
-  const tabs = useTabs()
-  const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
-  return (
-    <div class="relative shrink-0">
-      <Show when={hasOpenTab()}>
-        <span
-          aria-hidden="true"
-          class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
-          style={{ right: "calc(100% + 4px)" }}
-        />
-      </Show>
-      <SessionTabAvatar
-        project={props.project}
-        directory={props.session.directory}
-        sessionId={props.session.id}
-        server={props.server}
-        revealProjectOnHover={props.revealProjectOnHover}
-      />
-    </div>
-  )
-}
-
-function HomeSessionSearch(props: {
-  value: string
-  placeholder: string
-  open: boolean
-  loading: boolean
-  results: HomeSessionRecord[]
-  showProjectName: boolean
-  server: ServerConnection.Key
-  noResultsLabel: string
-  bindFocus: (focus: () => void) => void
-  onInput: (value: string) => void
-  onFocus: () => void
-  onClose: () => void
-  onSelect: (session: Session, options?: OpenSessionOptions) => void
-}) {
-  const language = useLanguage()
-  const [store, setStore] = createStore({ active: "" })
-  let root: HTMLDivElement | undefined
-  let input: HTMLInputElement | undefined
-  let listRef: HTMLDivElement | undefined
-
-  const focusInput = () => {
-    input?.focus()
-    props.onFocus()
-  }
-
-  onMount(() => {
-    props.bindFocus(focusInput)
-  })
-
-  const syncActive = (results: HomeSessionRecord[]) => {
-    if (results.length === 0) {
-      setStore("active", "")
-      return
-    }
-    if (!results.some((record) => homeSessionSearchKey(record) === store.active)) {
-      setStore("active", homeSessionSearchKey(results[0]))
-    }
-  }
-
-  createEffect(() => syncActive(props.results))
-
-  createEffect(
-    on(
-      () => props.value,
-      () => syncActive(props.results),
-    ),
-  )
-
-  const scrollActiveIntoView = () => {
-    const key = store.active
-    if (!key || !listRef) return
-    const element = listRef.querySelector<HTMLElement>(`[data-key="${key}"]`)
-    element?.scrollIntoView({ block: "nearest" })
-  }
-
-  const moveActive = (delta: number) => {
-    const results = props.results
-    if (results.length === 0) return
-    const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active)
-    const start = index === -1 ? 0 : index
-    const next = (start + delta + results.length) % results.length
-    setStore("active", homeSessionSearchKey(results[next]))
-    scrollActiveIntoView()
-  }
-
-  const selectActive = () => {
-    const record = props.results.find((item) => homeSessionSearchKey(item) === store.active)
-    if (!record) return
-    props.onSelect(record.session)
-  }
-
-  onCleanup(
-    makeEventListener(document, "pointerdown", (event) => {
-      if (!props.open) return
-      const target = event.target
-      if (!(target instanceof Node)) return
-      if (root?.contains(target)) return
-      props.onClose()
-    }),
-  )
-
-  return (
-    <div class="w-full">
-      <div ref={root} data-component="home-session-search" class="relative z-30 w-full">
-        <Show when={props.open}>
-          <div
-            data-component="home-session-search-panel"
-            class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
-            style={{
-              top: "-6px",
-              left: "-6px",
-              width: "calc(100% + 12px)",
-            }}
-          >
-            <div class="flex flex-col pt-9">
-              <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
-                <Show
-                  when={!props.loading}
-                  fallback={
-                    <div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
-                      <Spinner class="size-4" />
-                    </div>
-                  }
-                >
-                  <Show
-                    when={props.results.length > 0}
-                    fallback={
-                      <p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
-                        {props.noResultsLabel}
-                      </p>
-                    }
-                  >
-                    <div class="flex flex-col">
-                      <p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
-                        {language.t("home.sessions.search.sessions")}
-                      </p>
-                      <ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
-                        <div class="flex flex-col gap-px pb-2">
-                          <For each={props.results}>
-                            {(record) => (
-                              <HomeSessionSearchResultRow
-                                record={record}
-                                showProjectName={props.showProjectName}
-                                server={props.server}
-                                selected={store.active === homeSessionSearchKey(record)}
-                                onHighlight={() => setStore("active", homeSessionSearchKey(record))}
-                                onSelect={(session, options) => props.onSelect(session, options)}
-                              />
-                            )}
-                          </For>
-                        </div>
-                      </ScrollView>
-                    </div>
-                  </Show>
-                </Show>
-              </div>
-            </div>
-          </div>
-        </Show>
-        <label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
-          <IconV2 name="magnifying-glass" />
-          <input
-            ref={input}
-            class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
-            value={props.value}
-            placeholder={props.placeholder}
-            aria-label={props.placeholder}
-            aria-expanded={props.open}
-            aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
-            aria-autocomplete="list"
-            aria-activedescendant={
-              store.active && props.open ? `home-session-search-option-${store.active}` : undefined
-            }
-            onFocus={() => props.onFocus()}
-            onInput={(event) => props.onInput(event.currentTarget.value)}
-            onKeyDown={(event) => {
-              if (event.key === "Escape") {
-                event.preventDefault()
-                props.onClose()
-                input?.blur()
-                return
-              }
-              if (!props.open || props.results.length === 0) return
-              if (event.altKey || event.metaKey) return
-              if (event.key === "ArrowDown") {
-                event.preventDefault()
-                moveActive(1)
-                return
-              }
-              if (event.key === "ArrowUp") {
-                event.preventDefault()
-                moveActive(-1)
-                return
-              }
-              if (event.key === "Enter" && !event.isComposing) {
-                event.preventDefault()
-                selectActive()
-              }
-            }}
-          />
-          <Show when={props.value}>
-            <IconButtonV2
-              type="button"
-              variant="ghost-muted"
-              size="small"
-              class="relative z-20 shrink-0"
-              icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
-              aria-label={props.placeholder}
-              onClick={() => {
-                props.onClose()
-                input?.focus()
-              }}
-            />
-          </Show>
-        </label>
-      </div>
-    </div>
-  )
-}
-
-function HomeSessionSearchResultRow(props: {
-  record: HomeSessionRecord
-  showProjectName: boolean
-  server: ServerConnection.Key
-  selected: boolean
-  onHighlight: () => void
-  onSelect: (session: Session, options?: OpenSessionOptions) => void
-}) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.showProjectName && props.record.projectName
-
-  const key = () => homeSessionSearchKey(props.record)
-
-  return (
-    <button
-      type="button"
-      id={`home-session-search-option-${key()}`}
-      data-key={key()}
-      data-component="home-session-search-row"
-      role="option"
-      aria-selected={props.selected}
-      classList={{
-        [HOME_SEARCH_RESULT_ROW]: true,
-        "bg-v2-overlay-simple-overlay-hover": props.selected,
-        group: !!showProjectName(),
-      }}
-      onMouseEnter={() => props.onHighlight()}
-      onMouseDown={(event) => {
-        if (event.button === 1) event.preventDefault()
-      }}
-      onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
-      onAuxClick={(event) => {
-        if (!isBackgroundOpen(event)) return
-        event.preventDefault()
-        props.onSelect(props.record.session, { background: true })
-      }}
-    >
-      <HomeSessionLeading
-        project={props.record.project}
-        session={props.record.session}
-        server={props.server}
-        revealProjectOnHover={!!showProjectName()}
-      />
-      <div class="flex min-w-0 flex-1 items-center gap-1.5">
-        <span
-          class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
-        >
-          {title()}
-        </span>
-        <Show when={showProjectName()}>
-          <span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
-        </Show>
-      </div>
-    </button>
-  )
-}
-
-function HomeSessionGroupHeader(props: {
-  title: string
-  titleOpacity: number
-  ref: ComponentProps<"div">["ref"]
-  elevated?: boolean
-}) {
-  return (
-    <div
-      ref={props.ref}
-      class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
-    >
-      <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
-        {props.title}
-      </div>
-    </div>
-  )
-}
-
-function HomeSessionRow(props: {
-  record: HomeSessionRecord
-  showProjectName: boolean
-  server: ServerConnection.Key
-  openSession: (session: Session, options?: OpenSessionOptions) => void
-  archiveSession: (session: Session) => Promise<void>
-}) {
-  const language = useLanguage()
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.showProjectName && props.record.projectName
-
-  return (
-    <div
-      class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
-      classList={{ group: !!showProjectName() }}
-    >
-      <button
-        type="button"
-        data-component="home-session-row"
-        class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
-        onMouseDown={(event) => {
-          if (event.button === 1) event.preventDefault()
-        }}
-        onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
-        onAuxClick={(event) => {
-          if (!isBackgroundOpen(event)) return
-          event.preventDefault()
-          props.openSession(props.record.session, { background: true })
-        }}
-      >
-        <HomeSessionLeading
-          project={props.record.project}
-          session={props.record.session}
-          server={props.server}
-          revealProjectOnHover={!!showProjectName()}
-        />
-        <span
-          class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
-        >
-          {title()}
-        </span>
-        <Show when={showProjectName()}>
-          <span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
-            {props.record.projectName}
-          </span>
-        </Show>
-      </button>
-      <Show when={SHOW_HOME_SESSION_ARCHIVE}>
-        <div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
-          <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
-            <IconButtonV2
-              data-action="home-session-archive"
-              variant="ghost-muted"
-              size="large"
-              icon={<IconV2 name="archive" />}
-              aria-label={language.t("common.archive")}
-              onClick={(event) => {
-                event.preventDefault()
-                event.stopPropagation()
-                void props.archiveSession(props.record.session)
-              }}
-            />
-          </TooltipV2>
-        </div>
-      </Show>
-    </div>
-  )
-}
-
-function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
-  const language = useLanguage()
-  return (
-    <div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
-      <div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
-        {language.t("home.sessions.empty")}
-      </div>
-      <p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
-        {language.t("home.sessions.empty.description")}
-      </p>
-      <Show when={props.onNewSession}>
-        {(onNewSession) => (
-          <ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
-            {language.t("command.session.new")}
-          </ButtonV2>
-        )}
-      </Show>
-    </div>
-  )
-}
-
-function HomeSessionSkeleton(props: { label: string }) {
-  return (
-    <div class="flex min-w-0 flex-col gap-4">
-      <div class="flex h-7 min-w-0 items-center justify-between px-4">
-        <div class={HOME_SECTION_LABEL}>{props.label}</div>
-      </div>
-      <div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
-        <For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
-      </div>
-    </div>
-  )
-}
-
-function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
-  const now = DateTime.local()
-  const yesterday = now.minus({ days: 1 })
-  const todaySessions = records.filter((record) =>
-    DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
-  )
-  const yesterdaySessions = records.filter((record) =>
-    DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
-  )
-  const olderSessions = records.filter((record) => {
-    const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
-    return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
-  })
-  const olderTitle =
-    todaySessions.length === 0 && yesterdaySessions.length === 0
-      ? language.t("sidebar.project.recentSessions")
-      : language.t("home.sessions.group.older")
-
-  return [
-    { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
-    { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
-    { id: "older" as const, title: olderTitle, sessions: olderSessions },
-  ].filter((group) => group.sessions.length > 0)
-}
-
-export function LegacyHome() {
-  const sync = useServerSync()
-  const platform = usePlatform()
-  const pickDirectory = useDirectoryPicker()
-  const dialog = useDialog()
-  const navigate = useNavigate()
-  const global = useGlobal()
-  const server = useServer()
-  const language = useLanguage()
-  const homedir = createMemo(() => sync().data.path.home)
-  const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
-  const recent = createMemo(() => {
-    return sync()
-      .data.project.slice()
-      .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
-      .slice(0, 5)
-  })
-
-  const serverDotClass = createMemo(() => {
-    const healthy = global.servers.health[server.key]?.healthy
-    if (healthy === true) return "bg-icon-success-base"
-    if (healthy === false) return "bg-icon-critical-base"
-    return "bg-border-weak-base"
-  })
-
-  function openProject(server: ServerConnection.Any, directory: string) {
-    const serverCtx = global.ensureServerCtx(server)
-    serverCtx.projects.open(directory)
-    serverCtx.projects.touch(directory)
-    navigate(`/${base64Encode(directory)}`)
-  }
-
-  function chooseProject() {
-    if (serverUnreachable()) return
-    const s = server.current
-    if (!s) return
-
-    const resolve = (result: string | string[] | null) => {
-      if (Array.isArray(result)) {
-        for (const directory of result) {
-          openProject(s, directory)
-        }
-      } else if (result) {
-        openProject(s, result)
-      }
-    }
-
-    pickDirectory({
-      server: s,
-      title: language.t("command.project.open"),
-      multiple: true,
-      onSelect: resolve,
-    })
-  }
-
-  return (
-    <div class="mx-auto mt-55 w-full md:w-auto px-4">
-      <Logo class="md:w-xl opacity-12" />
-      <Button
-        size="large"
-        variant="ghost"
-        class="mt-4 mx-auto text-14-regular text-text-weak"
-        onClick={() => dialog.show(() => <DialogSelectServer />)}
-      >
-        <div
-          classList={{
-            "size-2 rounded-full": true,
-            [serverDotClass()]: true,
-          }}
-        />
-        {server.name}
-      </Button>
-      <Switch>
-        <Match when={sync().data.project.length > 0}>
-          <div class="mt-20 w-full flex flex-col gap-4">
-            <div class="flex gap-2 items-center justify-between pl-3">
-              <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
-              <Button
-                icon="folder-add-left"
-                size="normal"
-                class="pl-2 pr-3"
-                disabled={serverUnreachable()}
-                onClick={chooseProject}
-              >
-                {language.t("command.project.open")}
-              </Button>
-            </div>
-            <ul class="flex flex-col gap-2">
-              <For each={recent()}>
-                {(project) => (
-                  <Button
-                    size="large"
-                    variant="ghost"
-                    class="text-14-mono text-left justify-between px-3"
-                    onClick={() => openProject(server.current!, project.worktree)}
-                  >
-                    {project.worktree.replace(homedir(), "~")}
-                    <div class="text-14-regular text-text-weak">
-                      {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
-                    </div>
-                  </Button>
-                )}
-              </For>
-            </ul>
-          </div>
-        </Match>
-        <Match when={!sync().ready}>
-          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
-            <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
-            <Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
-              {language.t("command.project.open")}
-            </Button>
-          </div>
-        </Match>
-        <Match when={true}>
-          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
-            <Icon name="folder-add-left" size="large" />
-            <div class="flex flex-col gap-1 items-center justify-center">
-              <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
-              <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
-            </div>
-            <Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
-              {language.t("command.project.open")}
-            </Button>
-          </div>
-        </Match>
-      </Switch>
-    </div>
-  )
-}

+ 108 - 0
packages/app/src/pages/home/home-controller.ts

@@ -0,0 +1,108 @@
+import { useGlobal } from "@/context/global"
+import { type HomeProjectSelection, useLayout } from "@/context/layout"
+import { ServerConnection, useServer } from "@/context/server"
+import { useServerSync } from "@/context/server-sync"
+import { useTabs } from "@/context/tabs"
+import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
+import { createEffect, createMemo } from "solid-js"
+
+export function createHomeController() {
+  const sync = useServerSync()
+  const layout = useLayout()
+  const server = useServer()
+  const global = useGlobal()
+  const tabs = useTabs()
+  const selection = layout.home.selection
+  const focusedServer = createMemo(
+    () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
+  )
+  const focusedServerCtx = createMemo(() => {
+    const conn = focusedServer()
+    if (!conn) return undefined
+    return global.ensureServerCtx(conn)
+  })
+  const focusedSync = () => focusedServerCtx()?.sync ?? sync()
+  const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
+  const recentlyClosed = createMemo(
+    () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
+  )
+  const homedir = createMemo(() => focusedSync().data.path.home ?? "")
+  const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
+  const newSessionProject = createMemo(
+    () =>
+      selectedProject() ??
+      projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
+      projects()[0],
+  )
+
+  createEffect(() => {
+    const list = global.servers.list()
+    if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
+    const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
+    if (conn) setSelection({ server: ServerConnection.key(conn) })
+  })
+
+  function setSelection(next: HomeProjectSelection) {
+    layout.home.setSelection(next)
+  }
+
+  function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
+    const ctx = global.ensureServerCtx(conn)
+    ctx.projects.open(directory)
+    ctx.projects.touch(directory)
+    void tabs.newDraft({ server: ServerConnection.key(conn), directory })
+  }
+
+  return {
+    selection: {
+      value: selection,
+      set: setSelection,
+      focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
+    },
+    server: {
+      list: global.servers.list,
+      health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
+      context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
+      focused: focusedServer,
+      focusedContext: focusedServerCtx,
+      focusedSync,
+    },
+    project: {
+      list: projects,
+      recentlyClosed,
+      homedir,
+      selected: selectedProject,
+      newSession: newSessionProject,
+      forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
+      select: (conn: ServerConnection.Any, directory: string) => {
+        const key = ServerConnection.key(conn)
+        if (global.servers.health[key]?.healthy === false) return
+        if (
+          !global
+            .ensureServerCtx(conn)
+            .projects.list()
+            .some((project) => project.worktree === directory)
+        )
+          return
+        setSelection(toggleHomeProjectSelection(selection(), key, directory))
+      },
+      add: (conn: ServerConnection.Any, directories: string[]) => {
+        const directory = directories[0]
+        if (!directory) return
+        const ctx = global.ensureServerCtx(conn)
+        directories.forEach((item) => ctx.projects.open(item))
+        ctx.projects.touch(directory)
+        setSelection({ server: ServerConnection.key(conn), directory })
+      },
+      openNewSession: () => {
+        const conn = focusedServer()
+        const project = newSessionProject()
+        if (!conn || !project) return
+        openProjectNewSession(conn, project.worktree)
+      },
+      openProjectNewSession,
+    },
+  }
+}
+
+export type HomeController = ReturnType<typeof createHomeController>

+ 128 - 0
packages/app/src/pages/home/home-projects-controller.tsx

@@ -0,0 +1,128 @@
+import { useDirectoryPicker } from "@/components/directory-picker"
+import { useServerManagementController } from "@/components/dialog-select-server"
+import { useSettingsCommand } from "@/components/settings-dialog"
+import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
+import { type LocalProject } from "@/context/layout"
+import { useLanguage } from "@/context/language"
+import { useNotification } from "@/context/notification"
+import { usePlatform } from "@/context/platform"
+import { ServerConnection } from "@/context/server"
+import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
+import { Persist, persisted } from "@/utils/persist"
+import { showToast } from "@/utils/toast"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createResource } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { HomeController } from "./home-controller"
+
+export function createHomeProjectsController(home: HomeController) {
+  const platform = usePlatform()
+  const pickDirectory = useDirectoryPicker()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const notification = useNotification()
+  const openSettings = useSettingsCommand()
+  const serverManagement = useServerManagementController({ navigateOnAdd: false })
+  const [_state, setState, _, ready] = persisted(
+    Persist.global("home.servers", ["home.servers.v1"]),
+    createStore({ collapsed: {} as Record<string, boolean> }),
+  )
+  const [state] = createResource(
+    () => ready.promise ?? Promise.resolve(),
+    (promise) => promise.then(() => _state),
+    { initialValue: _state },
+  )
+  function directories(project: LocalProject) {
+    return [project.worktree, ...(project.sandboxes ?? [])]
+  }
+
+  function canRevealProject(conn: ServerConnection.Any) {
+    return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
+  }
+
+  return {
+    copy: {
+      language,
+    },
+    selection: {
+      value: home.selection.value,
+    },
+    server: {
+      list: home.server.list,
+      health: home.server.health,
+      projects: home.project.forServer,
+      collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false,
+      toggleCollapsed: (conn: ServerConnection.Any) => {
+        const key = ServerConnection.key(conn)
+        setState("collapsed", key, !state().collapsed[key])
+      },
+      canDefault: serverManagement.canDefault,
+      defaultKey: serverManagement.defaultKey,
+      setDefault: (conn: ServerConnection.Any | undefined) =>
+        serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
+      remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
+      edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
+      focus: home.selection.focusServer,
+    },
+    project: {
+      list: home.project.list,
+      recentlyClosed: home.project.recentlyClosed,
+      homedir: home.project.homedir,
+      select: home.project.select,
+      add: home.project.add,
+      openNewSession: home.project.openProjectNewSession,
+      edit: (conn: ServerConnection.Any, project: LocalProject) => {
+        void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
+          void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
+        })
+      },
+      unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+        const state = notification.ensureServerState(ServerConnection.key(conn))
+        return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
+      },
+      clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
+        const state = notification.ensureServerState(ServerConnection.key(conn))
+        directories(project)
+          .filter((directory) => state.project.unseenCount(directory) > 0)
+          .forEach((directory) => state.project.markViewed(directory))
+      },
+      choose: (conn: ServerConnection.Any) => {
+        if (home.server.health(conn)?.healthy === false) return
+        pickDirectory({
+          server: conn,
+          title: language.t("command.project.open"),
+          multiple: true,
+          onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
+        })
+      },
+      close: (conn: ServerConnection.Any, directory: string) => {
+        const next = closeHomeProject(
+          home.selection.value(),
+          ServerConnection.key(conn),
+          home.server.context(conn).projects,
+          directory,
+        )
+        if (next) home.selection.set(next)
+      },
+      move: (conn: ServerConnection.Any, worktree: string, index: number) => {
+        home.server.context(conn).projects.move(worktree, index)
+      },
+      canReveal: canRevealProject,
+      reveal: (conn: ServerConnection.Any, project: LocalProject) => {
+        if (!platform.openPath || !canRevealProject(conn)) return
+        platform.openPath(project.worktree).catch((cause: unknown) =>
+          showToast({
+            title: language.t("common.requestFailed"),
+            description: errorMessage(cause, language.t("common.requestFailed")),
+          }),
+        )
+      },
+    },
+    utility: {
+      settings: openSettings,
+      help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
+    },
+  }
+}
+
+export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>

+ 615 - 0
packages/app/src/pages/home/home-projects-view.tsx

@@ -0,0 +1,615 @@
+import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
+import { createStore } from "solid-js/store"
+import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
+import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
+import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
+import { ServerHealthIndicator } from "@/components/server/server-row"
+import { type ServerHealth } from "@/utils/server-health"
+import { fileManagerApp } from "@/utils/file-manager"
+
+const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
+
+const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
+const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
+  `project:${ServerConnection.key(server)}:${directory}`
+
+export type HomeProjectsViewProps = {
+  language: ReturnType<typeof useLanguage>
+  servers: Accessor<ServerConnection.Any[]>
+  projects: Accessor<LocalProject[]>
+  recentlyClosed: Accessor<LocalProject[]>
+  selection: Accessor<HomeProjectSelection>
+  homedir: Accessor<string>
+  serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
+  projectsForServer: (server: ServerConnection.Any) => LocalProject[]
+  collapsed: (server: ServerConnection.Any) => boolean
+  canDefaultServer: Accessor<boolean>
+  defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
+  canRevealProject: (server: ServerConnection.Any) => boolean
+  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
+  onWheel: (event: WheelEvent) => void
+  onChooseProject: (server: ServerConnection.Any) => void
+  onFocusServer: (server: ServerConnection.Any) => void
+  onToggleCollapsed: (server: ServerConnection.Any) => void
+  onEditServer: (server: ServerConnection.Http) => void
+  onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
+  onRemoveServer: (server: ServerConnection.Any) => void
+  onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
+  onSelectProject: (server: ServerConnection.Any, directory: string) => void
+  onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
+  onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
+  onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
+  onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
+  onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
+  onCloseProject: (server: ServerConnection.Any, directory: string) => void
+  onOpenSettings: () => void
+  onOpenHelp: () => void
+}
+
+export function HomeProjectsView(props: HomeProjectsViewProps) {
+  const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
+  const contextMenuProps = {
+    contextMenuOpen: (id: string) => contextMenu.open === id,
+    onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined),
+  }
+  return (
+    <aside
+      class={`
+        mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
+        lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
+      `}
+      aria-label={props.language.t("home.projects")}
+      onWheel={(event) => {
+        if (event.target === event.currentTarget) return
+        props.onWheel(event)
+      }}
+    >
+      <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
+        <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
+        <Show
+          when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
+        >
+          <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
+            <IconButtonV2
+              data-action="home-add-project"
+              variant="ghost-muted"
+              size="large"
+              class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+              icon={<IconV2 name="folder-add-left" />}
+              disabled={props.serverHealth(props.servers()[0])?.healthy === false}
+              onClick={() => props.onChooseProject(props.servers()[0])}
+              aria-label={props.language.t("home.project.add")}
+            />
+          </TooltipV2>
+        </Show>
+      </div>
+      <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
+        <Show
+          when={props.servers().length > 1}
+          fallback={
+            <div class="pr-3">
+              <Show
+                when={props.projects().length > 0}
+                fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
+              >
+                <HomeProjectList
+                  {...props}
+                  {...contextMenuProps}
+                  server={props.servers()[0]}
+                  items={props.projects()}
+                />
+              </Show>
+            </div>
+          }
+        >
+          <div class="flex min-w-0 flex-col gap-4 pr-3">
+            <For each={props.servers()}>
+              {(item) => {
+                const projects = () => props.projectsForServer(item)
+                const healthy = () => !!props.serverHealth(item)?.healthy
+                const hasProjects = () => projects().length > 0
+                const collapsed = () => props.collapsed(item)
+                return (
+                  <div class="flex min-w-0 flex-col gap-1">
+                    <HomeServerRow
+                      server={item}
+                      {...props}
+                      {...contextMenuProps}
+                      selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
+                      collapsed={collapsed()}
+                      health={props.serverHealth(item)}
+                    />
+                    <Show when={healthy() && hasProjects() && !collapsed()}>
+                      <div class="mx-3 h-px bg-v2-border-border-base" />
+                      <HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
+                    </Show>
+                  </div>
+                )
+              }}
+            </For>
+          </div>
+        </Show>
+      </ScrollView>
+      <HomeUtilityNav
+        class="mb-8 mt-4 hidden shrink-0 lg:flex"
+        onOpenSettings={props.onOpenSettings}
+        onOpenHelp={props.onOpenHelp}
+        language={props.language}
+      />
+    </aside>
+  )
+}
+
+export function HomeUtilityNav(props: {
+  class?: string
+  onOpenSettings: () => void
+  onOpenHelp: () => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  return (
+    <div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
+      <HomeProjectNavButton
+        type="button"
+        class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+        onClick={props.onOpenSettings}
+      >
+        <IconV2 name="settings-gear" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
+      </HomeProjectNavButton>
+      <HomeProjectNavButton
+        type="button"
+        class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+        onClick={props.onOpenHelp}
+      >
+        <IconV2 name="help" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
+      </HomeProjectNavButton>
+    </div>
+  )
+}
+
+function HomeServerRow(props: {
+  language: HomeProjectsViewProps["language"]
+  projectsForServer: HomeProjectsViewProps["projectsForServer"]
+  contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"]
+  canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
+  defaultServerKey: HomeProjectsViewProps["defaultServerKey"]
+  onFocusServer: HomeProjectsViewProps["onFocusServer"]
+  onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
+  onEditServer: HomeProjectsViewProps["onEditServer"]
+  onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
+  onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
+  onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
+  onChooseProject: HomeProjectsViewProps["onChooseProject"]
+  server: ServerConnection.Any
+  selected: boolean
+  collapsed: boolean
+  health: ServerHealth | undefined
+}) {
+  const healthy = () => !!props.health?.healthy
+  const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
+  const contextMenuID = () => serverContextMenuID(props.server)
+  onCleanup(() => {
+    const id = contextMenuID()
+    if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
+  })
+  return (
+    <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
+      <HomeProjectNavButton
+        type="button"
+        class="pr-16 disabled:opacity-60"
+        data-selected={props.selected ? "" : undefined}
+        disabled={!healthy()}
+        onClick={() => props.onFocusServer(props.server)}
+      >
+        <span
+          data-action="home-server-collapse"
+          class={`
+            -ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
+            rounded-[4px] text-v2-icon-icon-muted
+          `}
+          classList={{
+            "hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
+            "cursor-default opacity-40": !canToggle(),
+          }}
+          aria-label={
+            props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
+          }
+          aria-disabled={!canToggle()}
+          aria-expanded={canToggle() ? !props.collapsed : undefined}
+          onClick={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+            if (!canToggle()) return
+            props.onToggleCollapsed(props.server)
+          }}
+          onPointerDown={(event) => event.preventDefault()}
+        >
+          <IconV2
+            name="chevron-down"
+            size="small"
+            class="transition-transform duration-150 ease-in-out"
+            style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
+          />
+        </span>
+        <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
+          <ServerHealthIndicator health={props.health} />
+        </div>
+        <span class="flex min-w-0 items-center gap-1">
+          <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
+          <Show when={props.server.label}>
+            {(label) => (
+              <span
+                class={`
+                  shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
+                  text-[9px] leading-none text-v2-text-text-muted
+                `}
+              >
+                {label()}
+              </span>
+            )}
+          </Show>
+        </span>
+      </HomeProjectNavButton>
+      <div
+        class={`
+          hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
+          group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
+        `}
+        data-menu={props.contextMenuOpen(contextMenuID())}
+      >
+        <ServerRowMenuView
+          server={props.server}
+          labels={serverMenuLabels(props.language)}
+          canDefault={props.canDefaultServer()}
+          isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
+          onEdit={props.onEditServer}
+          onSetDefault={() => props.onSetDefaultServer(props.server)}
+          onRemoveDefault={() => props.onSetDefaultServer(undefined)}
+          onRemove={() => props.onRemoveServer(props.server)}
+          open={props.contextMenuOpen(contextMenuID())}
+          onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
+        />
+        <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
+          <IconButtonV2
+            data-action="home-add-project"
+            variant="ghost-muted"
+            size="small"
+            icon={<IconV2 name="folder-add-left" />}
+            aria-label={props.language.t("home.project.add")}
+            disabled={props.health?.healthy === false}
+            onClick={() => props.onChooseProject(props.server)}
+          />
+        </TooltipV2>
+      </div>
+    </div>
+  )
+}
+
+type HomeProjectsContextMenuProps = {
+  contextMenuOpen: (id: string) => boolean
+  onSetContextMenuOpen: (id: string, open: boolean) => void
+}
+
+type HomeProjectListProps = HomeProjectsViewProps &
+  HomeProjectsContextMenuProps & {
+    server: ServerConnection.Any
+    items: LocalProject[]
+  }
+
+function HomeProjectList(props: HomeProjectListProps) {
+  let listRef!: HTMLDivElement
+
+  return (
+    <DragDropProvider
+      sensors={(defaults) => [
+        ...defaults.filter((sensor) => sensor !== PointerSensor),
+        PointerSensor.configure({
+          activationConstraints: (event) =>
+            event.pointerType === "touch"
+              ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
+              : [new PointerActivationConstraints.Distance({ value: 4 })],
+          preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
+        }),
+      ]}
+      modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
+      plugins={(defaults) => [
+        ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
+        AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
+        Feedback.configure({ dropAnimation: null }),
+      ]}
+      onDragEnd={(event) => {
+        const source = event.operation.source
+        if (event.canceled || !isSortable(source)) return
+        if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
+        if (props.selection().server !== ServerConnection.key(props.server))
+          props.onSelectProject(props.server, source.id.toString())
+      }}
+    >
+      <div class="flex min-w-0 flex-col gap-1" ref={listRef}>
+        {/* Keyed on worktree strings: the enriched project objects are
+            recreated on every store or sync update, so iterating them directly
+            remounts all rows — killing any in-flight drag activation (the
+            row's sortable unregisters on unmount) and discarding animations.
+            String keys keep row elements alive and move them on reorder. */}
+        <For each={props.items.map((project) => project.worktree)}>
+          {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
+        </For>
+      </div>
+    </DragDropProvider>
+  )
+}
+
+function HomeProjectSlot(
+  props: HomeProjectListProps & {
+    worktree: string
+    index: () => number
+  },
+) {
+  const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
+
+  return (
+    <Show when={project()}>
+      {(item) => (
+        <HomeProjectRow
+          {...props}
+          project={item()}
+          server={props.server}
+          index={props.index}
+          serverSelected={props.selection().server === ServerConnection.key(props.server)}
+          selected={
+            props.selection().server === ServerConnection.key(props.server) &&
+            props.selection().directory === props.worktree
+          }
+          unseen={props.unseenCount(props.server, item())}
+        />
+      )}
+    </Show>
+  )
+}
+
+function HomeProjectEmpty(
+  props: HomeProjectsViewProps & {
+    server: ServerConnection.Any
+    items: LocalProject[]
+  },
+) {
+  const unreachable = () => props.serverHealth(props.server)?.healthy === false
+  return (
+    <div class="flex min-w-0 flex-col gap-1">
+      <HomeProjectNavButton
+        type="button"
+        data-action="home-add-project-row"
+        class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+        disabled={unreachable()}
+        onClick={() => props.onChooseProject(props.server)}
+      >
+        <IconV2 name="folder-add-left" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
+      </HomeProjectNavButton>
+      <Show when={props.items.length > 0}>
+        <div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
+          <div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
+        </div>
+        <For each={props.items}>
+          {(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
+        </For>
+      </Show>
+    </div>
+  )
+}
+
+function HomeRecentlyClosedRow(
+  props: HomeProjectsViewProps & {
+    project: LocalProject
+    server: ServerConnection.Any
+  },
+) {
+  const unreachable = () => props.serverHealth(props.server)?.healthy === false
+  const path = () => {
+    const home = props.homedir()
+    const worktree = props.project.worktree
+    if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
+    return worktree
+  }
+  return (
+    <TooltipV2 placement="right" value={path()}>
+      <HomeProjectNavButton
+        type="button"
+        data-component="home-recently-closed-row"
+        class="disabled:opacity-60"
+        disabled={unreachable()}
+        onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
+      >
+        <HomeProjectAvatar project={props.project} outline />
+        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
+      </HomeProjectNavButton>
+    </TooltipV2>
+  )
+}
+
+function HomeProjectRow(
+  props: HomeProjectsViewProps &
+    HomeProjectsContextMenuProps & {
+      project: LocalProject
+      server: ServerConnection.Any
+      index: () => number
+      serverSelected: boolean
+      selected: boolean
+      unseen: number
+    },
+) {
+  const platform = usePlatform()
+  const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
+  const sortable = useSortable({
+    get id() {
+      return props.project.worktree
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  let pointerDownSelected: boolean | undefined
+  const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree)
+  onCleanup(() => {
+    const id = contextMenuID()
+    if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
+  })
+  return (
+    <div
+      ref={sortable.ref}
+      class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
+      classList={{ "z-10": sortable.isDragSource() }}
+    >
+      <HomeProjectNavButton
+        type="button"
+        data-component="home-project-row"
+        class="pr-16 disabled:opacity-60"
+        classList={{
+          "bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
+          "[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
+        }}
+        data-selected={props.selected ? "" : undefined}
+        aria-current={props.selected ? "page" : undefined}
+        disabled={serverUnreachable()}
+        onPointerDown={(event) => {
+          // Same-server mouse selection happens on pointerdown (like tabs),
+          // but only ever selects; selectProject toggles, and deselecting here
+          // would fire on every drag before the threshold is met. Cross-server
+          // selection waits for click so reordering a remote server's projects
+          // does not focus that server and load its session index. Touch is
+          // excluded so flick-scrolling the list cannot select rows.
+          pointerDownSelected = undefined
+          if (event.button !== 0 || event.pointerType === "touch") return
+          if (!props.serverSelected) return
+          pointerDownSelected = props.selected
+          if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
+        }}
+        onClick={(event) => {
+          // The drag sensor calls preventDefault on post-drag clicks; never
+          // toggle selection as part of a reorder.
+          if (event.defaultPrevented) return
+          // Keyboard activation and touch taps keep the original toggle.
+          if (event.detail === 0 || pointerDownSelected === undefined) {
+            props.onSelectProject(props.server, props.project.worktree)
+            return
+          }
+          // Mouse: pointerdown already selected unselected rows; a plain click
+          // on an already-selected row toggles it off.
+          if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
+          pointerDownSelected = undefined
+        }}
+      >
+        <HomeProjectAvatar project={props.project} />
+        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
+      </HomeProjectNavButton>
+      <div
+        class={`
+          hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
+          group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
+        `}
+        data-menu={props.contextMenuOpen(contextMenuID())}
+      >
+        <MenuV2
+          gutter={6}
+          modal={false}
+          placement="bottom-end"
+          open={props.contextMenuOpen(contextMenuID())}
+          onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
+        >
+          <MenuV2.Trigger
+            as={IconButtonV2}
+            data-action="home-project-menu"
+            variant="ghost-muted"
+            size="small"
+            icon={<IconV2 name="outline-dots" />}
+            aria-label={props.language.t("common.moreOptions")}
+          />
+          <MenuV2.Portal>
+            <MenuV2.Content>
+              <MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
+                {props.language.t("command.session.new")}
+              </MenuV2.Item>
+              <MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
+                {props.language.t("dialog.project.edit.title")}
+              </MenuV2.Item>
+              <Show when={props.canRevealProject(props.server)}>
+                <MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
+                  {props.language.t(
+                    fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown")
+                      .actionLabel,
+                  )}
+                </MenuV2.Item>
+              </Show>
+              <MenuV2.Item
+                disabled={props.unseen === 0}
+                onSelect={() => props.onClearNotifications(props.server, props.project)}
+              >
+                {props.language.t("sidebar.project.clearNotifications")}
+              </MenuV2.Item>
+              <MenuV2.Separator />
+              <MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
+                {props.language.t("common.close")}
+              </MenuV2.Item>
+            </MenuV2.Content>
+          </MenuV2.Portal>
+        </MenuV2>
+        <IconButtonV2
+          data-action="home-project-new-session"
+          variant="ghost-muted"
+          size="small"
+          icon={<IconV2 name="edit" />}
+          aria-label={props.language.t("command.session.new")}
+          onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
+        />
+      </div>
+    </div>
+  )
+}
+
+function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
+  const [local, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <button
+      {...rest}
+      class={`
+        flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
+        text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
+        hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
+        data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
+        data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
+        focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
+        focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
+        ${local.class ?? ""}
+      `}
+      classList={local.classList}
+    >
+      {local.children}
+    </button>
+  )
+}
+
+function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
+  const name = createMemo(() => displayName(props.project))
+  return (
+    <ProjectAvatar
+      fallback={name()}
+      src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
+      variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
+    />
+  )
+}

+ 40 - 0
packages/app/src/pages/home/home-projects.tsx

@@ -0,0 +1,40 @@
+import type { HomeProjectsController } from "./home-projects-controller"
+import { HomeProjectsView } from "./home-projects-view"
+import type { HomeScrollController } from "./home-scroll-controller"
+
+export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
+  return (
+    <HomeProjectsView
+      language={props.projects.copy.language}
+      servers={props.projects.server.list}
+      projects={props.projects.project.list}
+      recentlyClosed={props.projects.project.recentlyClosed}
+      selection={props.projects.selection.value}
+      homedir={props.projects.project.homedir}
+      serverHealth={props.projects.server.health}
+      projectsForServer={props.projects.server.projects}
+      collapsed={props.projects.server.collapsed}
+      canDefaultServer={props.projects.server.canDefault}
+      defaultServerKey={props.projects.server.defaultKey}
+      canRevealProject={props.projects.project.canReveal}
+      unseenCount={props.projects.project.unseenCount}
+      onWheel={props.scroll.viewport.containWheel}
+      onChooseProject={props.projects.project.choose}
+      onFocusServer={props.projects.server.focus}
+      onToggleCollapsed={props.projects.server.toggleCollapsed}
+      onEditServer={props.projects.server.edit}
+      onSetDefaultServer={props.projects.server.setDefault}
+      onRemoveServer={props.projects.server.remove}
+      onMoveProject={props.projects.project.move}
+      onSelectProject={props.projects.project.select}
+      onAddProjects={props.projects.project.add}
+      onOpenProjectNewSession={props.projects.project.openNewSession}
+      onEditProject={props.projects.project.edit}
+      onRevealProject={props.projects.project.reveal}
+      onClearNotifications={props.projects.project.clearNotifications}
+      onCloseProject={props.projects.project.close}
+      onOpenSettings={props.projects.utility.settings}
+      onOpenHelp={props.projects.utility.help}
+    />
+  )
+}

+ 145 - 0
packages/app/src/pages/home/home-scroll-controller.ts

@@ -0,0 +1,145 @@
+import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { HomeSessionGroup } from "./home-sessions-controller"
+
+const HOME_SESSION_HEADER_STICKY_TOP = 12
+const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
+const HOME_SESSION_HEADER_FADE_DISTANCE = 16
+
+export function createHomeScrollController(groups: Accessor<HomeSessionGroup[]>) {
+  const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
+  const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
+  const [state, setState] = createStore({
+    titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
+  })
+  const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
+  const headerOffsets = new Map<HomeSessionGroup["id"], number>()
+  let viewport: HTMLDivElement | undefined
+  let content: HTMLDivElement | undefined
+  let positionFrame: number | undefined
+  let resizeObserver: ResizeObserver | undefined
+  let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
+
+  createEffect(() => {
+    const items = groups()
+    const ids = new Set(items.map((group) => group.id))
+    headerRefs.forEach((_, id) => {
+      if (!ids.has(id)) headerRefs.delete(id)
+    })
+    headerOffsets.forEach((_, id) => {
+      if (!ids.has(id)) headerOffsets.delete(id)
+    })
+    if (items.length === 0) {
+      content = undefined
+      bindResizeObserver()
+    }
+    queuePositionUpdate()
+  })
+
+  onCleanup(() => {
+    if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
+    resizeObserver?.disconnect()
+  })
+
+  function queuePositionUpdate() {
+    if (typeof requestAnimationFrame === "undefined") {
+      updatePositionCache()
+      return
+    }
+    if (positionFrame !== undefined) return
+    positionFrame = requestAnimationFrame(() => {
+      positionFrame = undefined
+      updatePositionCache()
+    })
+  }
+
+  function updatePositionCache() {
+    if (!viewport) return
+    const header = groups()
+      .map((group) => headerRefs.get(group.id))
+      .find((element) => element !== undefined)
+    if (header && typeof getComputedStyle === "function") {
+      const top = Number.parseFloat(getComputedStyle(header).top)
+      if (Number.isFinite(top)) stickyTop = top
+    }
+    groups().forEach((group) => {
+      const element = headerRefs.get(group.id)
+      if (element) headerOffsets.set(group.id, element.offsetTop)
+    })
+    update(viewport.scrollTop)
+  }
+
+  function update(scrollTop: number) {
+    const items = groups()
+    items.forEach((group, index) => {
+      const nextOffset = items
+        .slice(index + 1)
+        .map((item) => headerOffsets.get(item.id))
+        .find((offset) => offset !== undefined)
+      const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
+      const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
+      const opacity =
+        nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
+      setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
+    })
+  }
+
+  function bindResizeObserver() {
+    resizeObserver?.disconnect()
+    if (typeof ResizeObserver === "undefined") return
+    resizeObserver = new ResizeObserver(queuePositionUpdate)
+    if (viewport) resizeObserver.observe(viewport)
+    if (content) resizeObserver.observe(content)
+  }
+
+  function containWheel(event: WheelEvent) {
+    if (!viewport) return
+    if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
+    if (!(event.target instanceof Element)) return
+    const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
+    if (
+      scrollable !== viewport &&
+      scrollable &&
+      (event.deltaY < 0
+        ? scrollable.scrollTop > 0
+        : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
+    )
+      return
+    event.preventDefault()
+  }
+
+  return {
+    viewport: {
+      thumbTrack,
+      hoverTarget,
+      setThumbTrack,
+      setHoverTarget,
+      setViewport: (element: HTMLDivElement) => {
+        viewport = element
+        bindResizeObserver()
+        queuePositionUpdate()
+      },
+      update,
+      containWheel,
+      containOuterWheel: (event: WheelEvent) => {
+        if (!viewport) return
+        if (event.target instanceof Node && viewport.contains(event.target)) return
+        containWheel(event)
+      },
+    },
+    header: {
+      setContent: (element: HTMLDivElement) => {
+        content = element
+        bindResizeObserver()
+        queuePositionUpdate()
+      },
+      setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => {
+        headerRefs.set(id, element)
+        queuePositionUpdate()
+      },
+      titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
+    },
+  }
+}
+
+export type HomeScrollController = ReturnType<typeof createHomeScrollController>

+ 114 - 0
packages/app/src/pages/home/home-session-search-controller.ts

@@ -0,0 +1,114 @@
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { serverName } from "@/context/server"
+import { displayName } from "@/pages/layout/helpers"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { HomeController } from "./home-controller"
+import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
+
+type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
+
+export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
+  const command = useCommand()
+  const language = useLanguage()
+  const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
+  let root: HTMLDivElement | undefined
+  let input: HTMLInputElement | undefined
+  let list: HTMLDivElement | undefined
+  const query = createMemo(() => state.value.trim())
+  const results = createMemo(() => {
+    const value = query().toLowerCase()
+    if (!value) return []
+    return sessions.data
+      .searchRecords()
+      .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
+  })
+  const active = createMemo(() => {
+    const records = results()
+    if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted
+    return records[0] ? homeSessionSearchKey(records[0]) : ""
+  })
+  const open = createMemo(() => state.focused && query().length > 0)
+  const placeholder = createMemo(() => {
+    const project = home.project.selected()
+    if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
+    if (home.server.list().length > 1) {
+      const conn = home.server.focused()
+      if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
+    }
+    return language.t("home.sessions.search.placeholder")
+  })
+
+  onCleanup(
+    makeEventListener(document, "pointerdown", (event) => {
+      if (!open()) return
+      const target = event.target
+      if (!(target instanceof Node) || root?.contains(target)) return
+      close()
+    }),
+  )
+
+  command.register("home.search", () => [
+    {
+      id: "home.sessions.search.focus",
+      title: placeholder(),
+      keybind: "mod+f",
+      hidden: true,
+      onSelect: focus,
+    },
+  ])
+
+  function focus() {
+    input?.focus()
+    setState("focused", true)
+  }
+
+  function close() {
+    setState({ value: "", focused: false })
+  }
+
+  function select(record: HomeSessionRecord, options?: { background?: boolean }) {
+    sessions.session.open(record.session, options)
+    if (!options?.background) close()
+  }
+
+  return {
+    query: {
+      value: () => state.value,
+      placeholder,
+      open,
+      focus,
+      input: (value: string) => setState({ value, highlighted: "" }),
+      close,
+    },
+    result: {
+      loading: sessions.data.loading,
+      list: results,
+      active,
+      noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
+      highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)),
+      move: (delta: number) => {
+        const records = results()
+        if (records.length === 0) return
+        const index = records.findIndex((record) => homeSessionSearchKey(record) === active())
+        const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length
+        setState("highlighted", homeSessionSearchKey(records[next]))
+        list?.querySelector<HTMLElement>(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" })
+      },
+      select,
+      selectActive: () => {
+        const record = results().find((item) => homeSessionSearchKey(item) === active())
+        if (record) select(record)
+      },
+    },
+    element: {
+      setRoot: (element: HTMLDivElement) => (root = element),
+      setInput: (element: HTMLInputElement) => (input = element),
+      setList: (element: HTMLDivElement) => (list = element),
+    },
+  }
+}
+
+export type HomeSessionSearchController = ReturnType<typeof createHomeSessionSearchController>

+ 314 - 0
packages/app/src/pages/home/home-sessions-controller.tsx

@@ -0,0 +1,314 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useMarked } from "@opencode-ai/ui/context/marked"
+import { useQuery } from "@tanstack/solid-query"
+import { DateTime } from "luxon"
+import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
+import { produce } from "solid-js/store"
+import { useCommand } from "@/context/command"
+import {
+  loadHomeSessionIndex,
+  retainHomeSessions,
+  type HomeSessionEvents,
+} from "@/context/global-sync/home-session-index"
+import type { LocalProject } from "@/context/layout"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { sessionHasOpenTab, useTabs } from "@/context/tabs"
+import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
+import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
+import { pathKey } from "@/utils/path-key"
+import { showToast } from "@/utils/toast"
+import { Binary } from "@opencode-ai/core/util/binary"
+import { archiveHomeSession } from "../home-session-archive"
+import type { HomeController } from "./home-controller"
+
+const HOME_SESSION_LIMIT = 64
+export type HomeSessionRecord = {
+  session: Session
+  project: LocalProject
+  projectName: string
+}
+
+export type HomeSessionGroup = {
+  id: "today" | "yesterday" | "older"
+  title: string
+  sessions: HomeSessionRecord[]
+}
+
+export type OpenSessionOptions = { background?: boolean }
+
+export function createHomeSessionsController(home: HomeController) {
+  const tabs = useTabs()
+  const command = useCommand()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const marked = useMarked()
+  const projectDirectories = createMemo(() => {
+    const project = home.project.selected()
+    if (!project) return home.project.list().flatMap(directories)
+    return directories(project)
+  })
+  const projectByID = createMemo(
+    () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
+  )
+  const homeSessions = () => home.server.focusedSync().homeSessions
+  const sessionEventLoad = useQuery(() => ({
+    queryKey: homeSessions().eventsKey,
+    queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
+    initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
+    enabled: false,
+  }))
+  const sessionLoad = useQuery(() => ({
+    queryKey: homeSessions().indexKey,
+    enabled: !!home.server.focusedContext(),
+    queryFn: async ({ signal }) => {
+      const ctx = home.server.focusedContext()
+      if (!ctx) return { sessions: [], eventSequence: 0 }
+      const cache = homeSessions()
+      const eventSequence = cache.eventSequence()
+      const index = await loadHomeSessionIndex(
+        (input, options) => ctx.sdk.client.v2.session.list(input, options),
+        eventSequence,
+        signal,
+      )
+      cache.complete(eventSequence)
+      return index
+    },
+    retry: false,
+    staleTime: 30_000,
+    refetchOnMount: true,
+    refetchOnReconnect: true,
+  }))
+  const indexedSessions = createMemo(() =>
+    retainHomeSessions(
+      homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
+      HOME_SESSION_LIMIT,
+      Date.now(),
+    ),
+  )
+  const allRecords = createMemo(() =>
+    buildHomeSessionRecords({
+      sessions: indexedSessions,
+      projectDirectories,
+      projects: home.project.list,
+      projectByID,
+    }),
+  )
+  const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
+  const groups = createMemo(() => groupSessions(records(), language))
+  const prefetched = new Set<string>()
+
+  createEffect(() => {
+    const ctx = home.server.focusedContext()
+    const conn = home.server.focused()
+    if (!ctx || !conn) return
+    records()
+      .slice(0, 2)
+      .forEach((record) => {
+        const key = `${ServerConnection.key(conn)}\0${record.session.id}`
+        if (prefetched.has(key)) return
+        prefetched.add(key)
+        createRoot((dispose) => {
+          try {
+            void ctx.sync.session
+              .sync(record.session.id)
+              .then(() =>
+                Promise.all(
+                  (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
+                    (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
+                      if (part.type !== "text" || !part.text) return []
+                      return preloadMarkdown(part.text, part.id, marked)
+                    }),
+                  ),
+                ),
+              )
+              .catch(() => {})
+              .finally(dispose)
+          } catch {
+            dispose()
+          }
+        })
+      })
+  })
+
+  command.register("home.palette", () => [
+    {
+      id: "command.palette",
+      title: language.t("command.palette"),
+      hidden: true,
+      onSelect: async () => {
+        const conn = home.server.focused()
+        if (!conn) return
+        const ctx = home.server.focusedContext()
+        if (!ctx) return
+        const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
+        void dialog.show(() => (
+          <DialogHomeCommandPaletteV2
+            server={conn}
+            onSelectSession={(entry) => {
+              if (!entry.sessionID || !entry.directory || !entry.server) return
+              const sessionID = entry.sessionID
+              const server = entry.server
+              const directory = entry.project?.worktree ?? entry.directory
+              ctx.projects.open(directory)
+              ctx.projects.touch(directory)
+              void startTransition(() => {
+                const tab = tabs.addSessionTab({ server, sessionId: sessionID })
+                tabs.select(tab)
+              })
+            }}
+          />
+        ))
+      },
+    },
+  ])
+
+  return {
+    copy: {
+      language,
+    },
+    data: {
+      records,
+      groups,
+      loading: () => sessionLoad.isLoading,
+      searchRecords: allRecords,
+    },
+    session: {
+      showProjectName: () => !home.project.selected(),
+      server: () => home.selection.value().server,
+      canCreate: () => !!home.project.newSession(),
+      create: home.project.openNewSession,
+      open: (session: Session, options?: OpenSessionOptions) => {
+        const directoryKey = pathKey(session.directory)
+        const project =
+          home.project
+            .list()
+            .find(
+              (item) =>
+                pathKey(item.worktree) === directoryKey ||
+                item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
+            ) ?? projectForSession(session, home.project.list(), projectByID())
+        const conn = home.server.focused()
+        if (!conn) return
+        const directory = project?.worktree ?? session.directory
+        const ctx = home.server.focusedContext()
+        if (!ctx) return
+        ctx.projects.open(directory)
+        if (options?.background) {
+          tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+          return
+        }
+        ctx.projects.touch(directory)
+        void startTransition(() => {
+          const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+          tabs.select(tab)
+        })
+      },
+      archive: async (session: Session) => {
+        const conn = home.server.focused()
+        const ctx = home.server.focusedContext()
+        if (!conn || !ctx) return
+        const [, setStore] = ctx.sync.child(session.directory)
+        await archiveHomeSession({
+          server: ServerConnection.key(conn),
+          session,
+          archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
+          remove: () =>
+            setStore(
+              produce((draft) => {
+                const match = Binary.search(draft.session, session.id, (item) => item.id)
+                if (match.found) draft.session.splice(match.index, 1)
+              }),
+            ),
+          onError: (cause) =>
+            showToast({
+              title: language.t("common.requestFailed"),
+              description: errorMessage(cause, language.t("common.requestFailed")),
+            }),
+        })
+      },
+    },
+    tab: {
+      isOpen: (record: HomeSessionRecord) =>
+        sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
+    },
+  }
+}
+
+function directories(project: LocalProject) {
+  return [project.worktree, ...(project.sandboxes ?? [])]
+}
+
+function buildHomeSessionRecords(input: {
+  sessions: () => Session[]
+  projectDirectories: () => string[]
+  projects: () => LocalProject[]
+  projectByID: () => Map<string, LocalProject>
+}) {
+  const directories = new Set(input.projectDirectories().map(pathKey))
+  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
+  return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
+    .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+    .flatMap((session) => {
+      const directory = pathKey(session.directory)
+      const project =
+        input
+          .projects()
+          .find(
+            (item) =>
+              pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+          ) ?? projectForSession(session, input.projects(), input.projectByID())
+      if (!project) return []
+      return { session, project, projectName: displayName(project) }
+    })
+}
+
+export function homeSessionSearchKey(record: HomeSessionRecord) {
+  return `${pathKey(record.session.directory)}:${record.session.id}`
+}
+
+function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
+  const now = DateTime.local()
+  const yesterday = now.minus({ days: 1 })
+  const todaySessions = records.filter((record) =>
+    DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
+  )
+  const yesterdaySessions = records.filter((record) =>
+    DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
+  )
+  const olderSessions = records.filter((record) => {
+    const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
+    return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
+  })
+  const olderTitle =
+    todaySessions.length === 0 && yesterdaySessions.length === 0
+      ? language.t("sidebar.project.recentSessions")
+      : language.t("home.sessions.group.older")
+  return [
+    { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
+    { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
+    { id: "older" as const, title: olderTitle, sessions: olderSessions },
+  ].filter((group) => group.sessions.length > 0)
+}
+
+export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
+
+export function HomeSessionStatusController(props: {
+  server: Accessor<ServerConnection.Key>
+  record: HomeSessionRecord
+  isOpenTab: (record: HomeSessionRecord) => boolean
+  render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
+}) {
+  const avatar = useSessionTabAvatarState(
+    props.server,
+    () => props.record.session.directory,
+    () => props.record.session.id,
+  )
+  return props.render({
+    unread: avatar.unread,
+    loading: avatar.loading,
+    open: () => props.isOpenTab(props.record),
+  })
+}

+ 550 - 0
packages/app/src/pages/home/home-sessions-view.tsx

@@ -0,0 +1,550 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { type Accessor, createMemo, For, Show } from "solid-js"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
+import { sessionTitle } from "@/utils/session-title"
+import { shouldOpenSessionInBackground } from "../home-session-open"
+import {
+  HomeSessionStatusController,
+  homeSessionSearchKey,
+  type HomeSessionGroup,
+  type HomeSessionRecord,
+  type OpenSessionOptions,
+} from "./home-sessions-controller"
+
+const SHOW_HOME_SESSION_ARCHIVE = false
+const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
+const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
+
+// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
+// tab in the background without navigating, matching browser conventions.
+function isBackgroundOpen(event: MouseEvent) {
+  return shouldOpenSessionInBackground({
+    button: event.button,
+    mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
+    meta: event.metaKey,
+    ctrl: event.ctrlKey,
+    shift: event.shiftKey,
+    alt: event.altKey,
+  })
+}
+
+export type HomeSessionsViewProps = {
+  language: ReturnType<typeof useLanguage>
+  groups: Accessor<HomeSessionGroup[]>
+  loading: Accessor<boolean>
+  showProjectName: Accessor<boolean>
+  server: Accessor<ServerConnection.Key>
+  canCreateSession: Accessor<boolean>
+  searchValue: Accessor<string>
+  searchPlaceholder: Accessor<string>
+  searchOpen: Accessor<boolean>
+  searchLoading: Accessor<boolean>
+  searchResults: Accessor<HomeSessionRecord[]>
+  searchActive: Accessor<string>
+  searchNoResultsLabel: Accessor<string>
+  titleOpacity: (id: HomeSessionGroup["id"]) => number
+  isOpenTab: (record: HomeSessionRecord) => boolean
+  onCreateSession: () => void
+  onOpenSession: (session: Session, options?: OpenSessionOptions) => void
+  onArchiveSession: (session: Session) => Promise<void>
+  onSetHoverTarget: (element: HTMLElement) => void
+  onSetThumbTrack: (element: HTMLDivElement) => void
+  onSetContent: (element: HTMLDivElement) => void
+  onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
+  onWheel: (event: WheelEvent) => void
+  onSetSearchRoot: (element: HTMLDivElement) => void
+  onSetSearchInput: (element: HTMLInputElement) => void
+  onSetSearchList: (element: HTMLDivElement) => void
+  onSearchFocus: () => void
+  onSearchInput: (value: string) => void
+  onSearchClose: () => void
+  onSearchMove: (delta: number) => void
+  onSearchSelectActive: () => void
+  onSearchHighlight: (record: HomeSessionRecord) => void
+  onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
+}
+
+export function HomeSessionsView(props: HomeSessionsViewProps) {
+  return (
+    <section
+      ref={props.onSetHoverTarget}
+      class="min-h-0 min-w-0 flex-1 flex flex-col"
+      aria-label={props.language.t("sidebar.project.recentSessions")}
+    >
+      <div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
+        <HomeSessionSearch {...props} />
+        <Show when={props.groups().length > 0 && props.canCreateSession()}>
+          <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
+            <ButtonV2
+              data-action="home-new-session"
+              variant="ghost-muted"
+              size="normal"
+              icon="edit"
+              class="pointer-events-auto h-7 px-2 [font-weight:530]"
+              onClick={props.onCreateSession}
+            >
+              {props.language.t("command.session.new")}
+            </ButtonV2>
+          </div>
+        </Show>
+      </div>
+      <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
+        <div
+          ref={props.onSetThumbTrack}
+          data-component="home-session-scroll-track"
+          class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
+        />
+      </div>
+      <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
+        <Show
+          when={!props.loading()}
+          fallback={
+            <div class="pt-3">
+              <HomeSessionSkeleton label={props.language.t("common.loading")} />
+            </div>
+          }
+        >
+          <Show
+            when={props.groups().length > 0}
+            fallback={
+              <HomeSessionsEmpty
+                onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
+                language={props.language}
+              />
+            }
+          >
+            <div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
+              <For each={props.groups()}>
+                {(group, index) => (
+                  <>
+                    <HomeSessionGroupHeader
+                      title={group.title}
+                      titleOpacity={props.titleOpacity(group.id)}
+                      onSetRef={(element) => props.onSetHeader(group.id, element)}
+                      elevated={index() === 0}
+                    />
+                    <div
+                      class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
+                    >
+                      <For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
+                    </div>
+                  </>
+                )}
+              </For>
+            </div>
+          </Show>
+        </Show>
+      </div>
+    </section>
+  )
+}
+
+function HomeSessionLeadingController(props: {
+  server: HomeSessionsViewProps["server"]
+  isOpenTab: HomeSessionsViewProps["isOpenTab"]
+  record: HomeSessionRecord
+  revealProjectOnHover: boolean
+}) {
+  return (
+    <HomeSessionStatusController
+      server={props.server}
+      record={props.record}
+      isOpenTab={props.isOpenTab}
+      render={(state) => (
+        <HomeSessionLeading
+          record={props.record}
+          revealProjectOnHover={props.revealProjectOnHover}
+          open={state.open()}
+          unread={state.unread()}
+          loading={state.loading()}
+        />
+      )}
+    />
+  )
+}
+
+function HomeSessionLeading(props: {
+  record: HomeSessionRecord
+  revealProjectOnHover: boolean
+  open: boolean
+  unread: boolean
+  loading: boolean
+}) {
+  return (
+    <div class="relative shrink-0">
+      <Show when={props.open}>
+        <span
+          aria-hidden="true"
+          class={`
+            pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2
+            rounded-[2px] bg-v2-background-bg-layer-04
+          `}
+          style={{ right: "calc(100% + 4px)" }}
+        />
+      </Show>
+      <SessionTabAvatarView
+        project={props.record.project}
+        directory={props.record.session.directory}
+        revealProjectOnHover={props.revealProjectOnHover}
+        unread={props.unread}
+        loading={props.loading}
+      />
+    </div>
+  )
+}
+
+function HomeSessionSearch(props: HomeSessionsViewProps) {
+  return (
+    <div class="w-full">
+      <div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
+        <Show when={props.searchOpen()}>
+          <div
+            data-component="home-session-search-panel"
+            class={`
+              absolute flex flex-col overflow-hidden rounded-[12px]
+              bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
+            `}
+            style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
+          >
+            <div class="flex flex-col pt-9">
+              <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
+                <Show
+                  when={!props.searchLoading()}
+                  fallback={
+                    <div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
+                      <Spinner class="size-4" />
+                    </div>
+                  }
+                >
+                  <Show
+                    when={props.searchResults().length > 0}
+                    fallback={
+                      <p
+                        class={`
+                          my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px]
+                          text-v2-text-text-muted [font-weight:440]
+                        `}
+                      >
+                        {props.searchNoResultsLabel()}
+                      </p>
+                    }
+                  >
+                    <div class="flex flex-col">
+                      <p
+                        class={`
+                          my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px]
+                          text-v2-text-text-muted [font-weight:440]
+                        `}
+                      >
+                        {props.language.t("home.sessions.search.sessions")}
+                      </p>
+                      <ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
+                        <div class="flex flex-col gap-px pb-2">
+                          <For each={props.searchResults()}>
+                            {(record) => (
+                              <HomeSessionSearchResultRow
+                                {...props}
+                                record={record}
+                                selected={props.searchActive() === homeSessionSearchKey(record)}
+                              />
+                            )}
+                          </For>
+                        </div>
+                      </ScrollView>
+                    </div>
+                  </Show>
+                </Show>
+              </div>
+            </div>
+          </div>
+        </Show>
+        <label
+          class={`
+            relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
+            bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
+            duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
+          `}
+        >
+          <IconV2 name="magnifying-glass" />
+          <input
+            ref={props.onSetSearchInput}
+            class={`
+              relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
+              text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
+            `}
+            value={props.searchValue()}
+            placeholder={props.searchPlaceholder()}
+            aria-label={props.searchPlaceholder()}
+            aria-expanded={props.searchOpen()}
+            aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
+            aria-autocomplete="list"
+            aria-activedescendant={
+              props.searchActive() && props.searchOpen()
+                ? `home-session-search-option-${props.searchActive()}`
+                : undefined
+            }
+            onFocus={props.onSearchFocus}
+            onInput={(event) => props.onSearchInput(event.currentTarget.value)}
+            onKeyDown={(event) => {
+              if (event.key === "Escape") {
+                event.preventDefault()
+                props.onSearchClose()
+                event.currentTarget.blur()
+                return
+              }
+              if (!props.searchOpen() || props.searchResults().length === 0) return
+              if (event.altKey || event.metaKey) return
+              if (event.key === "ArrowDown") {
+                event.preventDefault()
+                props.onSearchMove(1)
+                return
+              }
+              if (event.key === "ArrowUp") {
+                event.preventDefault()
+                props.onSearchMove(-1)
+                return
+              }
+              if (event.key === "Enter" && !event.isComposing) {
+                event.preventDefault()
+                props.onSearchSelectActive()
+              }
+            }}
+          />
+          <Show when={props.searchValue()}>
+            <IconButtonV2
+              type="button"
+              variant="ghost-muted"
+              size="small"
+              class="relative z-20 shrink-0"
+              icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
+              aria-label={props.searchPlaceholder()}
+              onClick={() => {
+                props.onSearchClose()
+                props.onSearchFocus()
+              }}
+            />
+          </Show>
+        </label>
+      </div>
+    </div>
+  )
+}
+
+function HomeSessionSearchResultRow(
+  props: HomeSessionsViewProps & {
+    record: HomeSessionRecord
+    selected: boolean
+  },
+) {
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const showProjectName = () => props.showProjectName() && props.record.projectName
+  const key = () => homeSessionSearchKey(props.record)
+
+  return (
+    <button
+      type="button"
+      id={`home-session-search-option-${key()}`}
+      data-key={key()}
+      data-component="home-session-search-row"
+      role="option"
+      aria-selected={props.selected}
+      class={`
+        flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left
+        transition-[background-color] duration-[120ms] ease-in-out
+        hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
+      `}
+      classList={{
+        "bg-v2-overlay-simple-overlay-hover": props.selected,
+        group: !!showProjectName(),
+      }}
+      onMouseEnter={() => props.onSearchHighlight(props.record)}
+      onMouseDown={(event) => {
+        if (event.button === 1) event.preventDefault()
+      }}
+      onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
+      onAuxClick={(event) => {
+        if (!isBackgroundOpen(event)) return
+        event.preventDefault()
+        props.onSearchSelect(props.record, { background: true })
+      }}
+    >
+      <HomeSessionLeadingController
+        server={props.server}
+        isOpenTab={props.isOpenTab}
+        record={props.record}
+        revealProjectOnHover={!!showProjectName()}
+      />
+      <div class="flex min-w-0 flex-1 items-center gap-1.5">
+        <HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
+        <Show when={showProjectName()}>
+          <HomeSessionProjectName name={props.record.projectName} search />
+        </Show>
+      </div>
+    </button>
+  )
+}
+
+function HomeSessionGroupHeader(props: {
+  title: string
+  titleOpacity: number
+  onSetRef: (element: HTMLDivElement) => void
+  elevated?: boolean
+}) {
+  return (
+    <div
+      ref={props.onSetRef}
+      class={`
+        pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
+        bg-v2-background-bg-base pl-3 lg:top-[108px]
+      `}
+      classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
+    >
+      <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
+        {props.title}
+      </div>
+    </div>
+  )
+}
+
+function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const showProjectName = () => props.showProjectName() && props.record.projectName
+
+  return (
+    <div
+      class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
+      classList={{ group: !!showProjectName() }}
+    >
+      <button
+        type="button"
+        data-component="home-session-row"
+        class={`
+          flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
+          bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
+          transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
+          hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
+        `}
+        onMouseDown={(event) => {
+          if (event.button === 1) event.preventDefault()
+        }}
+        onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
+        onAuxClick={(event) => {
+          if (!isBackgroundOpen(event)) return
+          event.preventDefault()
+          props.onOpenSession(props.record.session, { background: true })
+        }}
+      >
+        <HomeSessionLeadingController
+          server={props.server}
+          isOpenTab={props.isOpenTab}
+          record={props.record}
+          revealProjectOnHover={!!showProjectName()}
+        />
+        <HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
+        <Show when={showProjectName()}>
+          <HomeSessionProjectName name={props.record.projectName} />
+        </Show>
+      </button>
+      <Show when={SHOW_HOME_SESSION_ARCHIVE}>
+        <div
+          class={`
+            hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1
+            group-hover/session:opacity-100 focus-within:opacity-100
+          `}
+        >
+          <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
+            <IconButtonV2
+              data-action="home-session-archive"
+              variant="ghost-muted"
+              size="large"
+              icon={<IconV2 name="archive" />}
+              aria-label={props.language.t("common.archive")}
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                void props.onArchiveSession(props.record.session)
+              }}
+            />
+          </TooltipV2>
+        </div>
+      </Show>
+    </div>
+  )
+}
+
+function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
+  return (
+    <span
+      class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
+      classList={{
+        "text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
+        "max-w-[min(70%,480px)] flex-[0_1_auto]": props.showProjectName,
+        "flex-[1_1_auto]": !props.showProjectName,
+      }}
+    >
+      {props.title}
+    </span>
+  )
+}
+
+function HomeSessionProjectName(props: { name: string; search?: boolean }) {
+  return (
+    <span
+      class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
+      classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
+    >
+      {props.name}
+    </span>
+  )
+}
+
+function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
+  return (
+    <div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
+      <div
+        class={`
+          shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
+          text-v2-text-text-base [font-weight:530]
+        `}
+      >
+        {props.language.t("home.sessions.empty")}
+      </div>
+      <p
+        class={`
+          mb-1 text-center text-[13px] leading-5 tracking-[-0.04px]
+          text-v2-text-text-muted [font-weight:440]
+        `}
+      >
+        {props.language.t("home.sessions.empty.description")}
+      </p>
+      <Show when={props.onNewSession}>
+        {(onNewSession) => (
+          <ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
+            {props.language.t("command.session.new")}
+          </ButtonV2>
+        )}
+      </Show>
+    </div>
+  )
+}
+
+function HomeSessionSkeleton(props: { label: string }) {
+  return (
+    <div class="flex min-w-0 flex-col gap-4">
+      <div class="flex h-7 min-w-0 items-center justify-between px-4">
+        <div class={HOME_SECTION_LABEL}>{props.label}</div>
+      </div>
+      <div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
+        <For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
+      </div>
+    </div>
+  )
+}

+ 48 - 0
packages/app/src/pages/home/home-sessions.tsx

@@ -0,0 +1,48 @@
+import type { HomeScrollController } from "./home-scroll-controller"
+import type { HomeSessionSearchController } from "./home-session-search-controller"
+import type { HomeSessionsController } from "./home-sessions-controller"
+import { HomeSessionsView } from "./home-sessions-view"
+
+export function HomeSessions(props: {
+  sessions: HomeSessionsController
+  search: HomeSessionSearchController
+  scroll: HomeScrollController
+}) {
+  return (
+    <HomeSessionsView
+      language={props.sessions.copy.language}
+      groups={props.sessions.data.groups}
+      loading={props.sessions.data.loading}
+      showProjectName={props.sessions.session.showProjectName}
+      server={props.sessions.session.server}
+      canCreateSession={props.sessions.session.canCreate}
+      searchValue={props.search.query.value}
+      searchPlaceholder={props.search.query.placeholder}
+      searchOpen={props.search.query.open}
+      searchLoading={props.search.result.loading}
+      searchResults={props.search.result.list}
+      searchActive={props.search.result.active}
+      searchNoResultsLabel={props.search.result.noResultsLabel}
+      titleOpacity={props.scroll.header.titleOpacity}
+      isOpenTab={props.sessions.tab.isOpen}
+      onCreateSession={props.sessions.session.create}
+      onOpenSession={props.sessions.session.open}
+      onArchiveSession={props.sessions.session.archive}
+      onSetHoverTarget={props.scroll.viewport.setHoverTarget}
+      onSetThumbTrack={props.scroll.viewport.setThumbTrack}
+      onSetContent={props.scroll.header.setContent}
+      onSetHeader={props.scroll.header.setHeader}
+      onWheel={props.scroll.viewport.containWheel}
+      onSetSearchRoot={props.search.element.setRoot}
+      onSetSearchInput={props.search.element.setInput}
+      onSetSearchList={props.search.element.setList}
+      onSearchFocus={props.search.query.focus}
+      onSearchInput={props.search.query.input}
+      onSearchClose={props.search.query.close}
+      onSearchMove={props.search.result.move}
+      onSearchSelectActive={props.search.result.selectActive}
+      onSearchHighlight={props.search.result.highlight}
+      onSearchSelect={props.search.result.select}
+    />
+  )
+}

+ 142 - 0
packages/app/src/pages/home/legacy-home.tsx

@@ -0,0 +1,142 @@
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { useDirectoryPicker } from "@/components/directory-picker"
+import { useGlobal } from "@/context/global"
+import { useLanguage } from "@/context/language"
+import { type ServerConnection, useServer } from "@/context/server"
+import { useServerSync } from "@/context/server-sync"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Logo } from "@opencode-ai/ui/logo"
+import { useNavigate } from "@solidjs/router"
+import { DateTime } from "luxon"
+import { createMemo, For, Match, Switch } from "solid-js"
+
+export function LegacyHome() {
+  const sync = useServerSync()
+  const pickDirectory = useDirectoryPicker()
+  const dialog = useDialog()
+  const navigate = useNavigate()
+  const global = useGlobal()
+  const server = useServer()
+  const language = useLanguage()
+  const homedir = createMemo(() => sync().data.path.home)
+  const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
+  const recent = createMemo(() => {
+    return sync()
+      .data.project.slice()
+      .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+      .slice(0, 5)
+  })
+
+  const serverDotClass = createMemo(() => {
+    const healthy = global.servers.health[server.key]?.healthy
+    if (healthy === true) return "bg-icon-success-base"
+    if (healthy === false) return "bg-icon-critical-base"
+    return "bg-border-weak-base"
+  })
+
+  function openProject(conn: ServerConnection.Any, directory: string) {
+    const serverCtx = global.ensureServerCtx(conn)
+    serverCtx.projects.open(directory)
+    serverCtx.projects.touch(directory)
+    navigate(`/${base64Encode(directory)}`)
+  }
+
+  function chooseProject() {
+    if (serverUnreachable()) return
+    const conn = server.current
+    if (!conn) return
+
+    const resolve = (result: string | string[] | null) => {
+      if (Array.isArray(result)) {
+        result.forEach((directory) => openProject(conn, directory))
+        return
+      }
+      if (result) openProject(conn, result)
+    }
+
+    pickDirectory({
+      server: conn,
+      title: language.t("command.project.open"),
+      multiple: true,
+      onSelect: resolve,
+    })
+  }
+
+  return (
+    <div class="mx-auto mt-55 w-full md:w-auto px-4">
+      <Logo class="md:w-xl opacity-12" />
+      <Button
+        size="large"
+        variant="ghost"
+        class="mt-4 mx-auto text-14-regular text-text-weak"
+        onClick={() => dialog.show(() => <DialogSelectServer />)}
+      >
+        <div
+          classList={{
+            "size-2 rounded-full": true,
+            [serverDotClass()]: true,
+          }}
+        />
+        {server.name}
+      </Button>
+      <Switch>
+        <Match when={sync().data.project.length > 0}>
+          <div class="mt-20 w-full flex flex-col gap-4">
+            <div class="flex gap-2 items-center justify-between pl-3">
+              <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
+              <Button
+                icon="folder-add-left"
+                size="normal"
+                class="pl-2 pr-3"
+                disabled={serverUnreachable()}
+                onClick={chooseProject}
+              >
+                {language.t("command.project.open")}
+              </Button>
+            </div>
+            <ul class="flex flex-col gap-2">
+              <For each={recent()}>
+                {(project) => (
+                  <Button
+                    size="large"
+                    variant="ghost"
+                    class="text-14-mono text-left justify-between px-3"
+                    onClick={() => openProject(server.current!, project.worktree)}
+                  >
+                    {project.worktree.replace(homedir(), "~")}
+                    <div class="text-14-regular text-text-weak">
+                      {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
+                    </div>
+                  </Button>
+                )}
+              </For>
+            </ul>
+          </div>
+        </Match>
+        <Match when={!sync().ready}>
+          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+            <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
+            <Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
+          </div>
+        </Match>
+        <Match when={true}>
+          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+            <Icon name="folder-add-left" size="large" />
+            <div class="flex flex-col gap-1 items-center justify-center">
+              <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
+              <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
+            </div>
+            <Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
+          </div>
+        </Match>
+      </Switch>
+    </div>
+  )
+}

+ 30 - 33
packages/app/src/pages/layout.tsx

@@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers"
 import { toaster } from "@opencode-ai/ui/toast"
 import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
 import { useServerSDK } from "@/context/server-sdk"
+import { normalizeProjectInfo } from "@/context/global-sync/utils"
 import { clearWorkspaceTerminals } from "@/context/terminal"
 import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
 import { useNotification } from "@/context/notification"
@@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
+import { listAllSessions } from "@/utils/session"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) {
     const index = sessions.findIndex((s) => s.id === session.id)
     const nextSession = sessions[index + 1] ?? sessions[index - 1]
 
-    await serverSDK().client.session.update({
-      directory: session.directory,
-      sessionID: session.id,
-      time: { archived: Date.now() },
-    })
+    await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
     setStore(
       produce((draft) => {
         const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) {
     }
     const refreshDirs = async (target?: string) => {
       if (!target || target === root || canOpen(target)) return canOpen(target)
-      const listed = await serverSDK()
-        .client.worktree.list({ directory: root })
-        .then((x) => x.data ?? [])
+      const listed = await Promise.resolve(
+        project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
+      )
+        .then((value) => (typeof value === "string" ? value : value.id))
+        .then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
+        .then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
         .catch(() => [] as string[])
       dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
       return canOpen(target)
@@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) {
       await Promise.all(
         dirs.map(async (item) => ({
           path: { directory: item },
-          session: await serverSDK()
-            .client.session.list({ directory: item })
-            .then((x) => x.data ?? [])
-            .catch(() => []),
+          session: await listAllSessions(serverSDK().api.session, {
+            directory: item,
+            parentID: null,
+            order: "desc",
+          }).catch(() => []),
         })),
       ),
       Date.now(),
@@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) {
     const name = next === getFilename(project.worktree) ? "" : next
 
     if (project.id && project.id !== "global") {
-      await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
+      const result = await serverSDK().api.project.update({ projectID: project.id, name })
+      serverSync().set("project", (items) =>
+        items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
+      )
       return
     }
 
@@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) {
     })
     const dismiss = () => toaster.dismiss(progress)
 
-    const sessions: Session[] = await serverSDK()
-      .client.session.list({ directory })
-      .then((x) => x.data ?? [])
-      .catch(() => [])
+    const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
 
     clearWorkspaceTerminals(
       directory,
@@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) {
       return
     }
 
-    const archivedAt = Date.now()
     await Promise.all(
       sessions
         .filter((session) => session.time.archived === undefined)
         .map((session) =>
           serverSDK()
-            .client.session.update({
-              sessionID: session.id,
-              directory: session.directory,
-              time: { archived: archivedAt },
-            })
+            .api.session.archive({ sessionID: session.id, directory: session.directory })
             .catch(() => undefined),
         ),
     )
@@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) {
 
     onMount(() => {
       serverSDK()
-        .client.vcs.status({ directory: props.directory })
-        .then((x) => {
-          const files = x.data ?? []
+        .api.vcs.status({ location: { directory: props.directory } })
+        .then((result) => {
+          const files = result.data
           const dirty = files.length > 0
           setData({ status: "ready", dirty })
         })
@@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) {
     })
 
     const refresh = async () => {
-      const sessions = await serverSDK()
-        .client.session.list({ directory: props.directory })
-        .then((x) => x.data ?? [])
-        .catch(() => [])
+      const sessions = await listAllSessions(serverSDK().api.session, {
+        directory: props.directory,
+        order: "desc",
+      }).catch(() => [])
       const active = sessions.filter((session) => session.time.archived === undefined)
       setState({ sessions: active })
     }
 
     onMount(() => {
       serverSDK()
-        .client.vcs.status({ directory: props.directory })
-        .then((x) => {
-          const files = x.data ?? []
+        .api.vcs.status({ location: { directory: props.directory } })
+        .then((result) => {
+          const files = result.data
           const dirty = files.length > 0
           setState({ status: "ready", dirty })
           void refresh()

+ 7 - 5
packages/app/src/pages/layout/project-avatar-state.ts

@@ -13,7 +13,6 @@ export function useSessionTabAvatarState(
   const global = useGlobal()
   const notification = useNotification()
   const permission = usePermission()
-  const permissionState = createMemo(() => permission.ensureServerState(server()))
   const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
   const sync = createMemo(() => {
     const conn = connection()
@@ -22,9 +21,10 @@ export function useSessionTabAvatarState(
   const hasPermissions = createMemo(() => {
     const serverSync = sync()
     if (!serverSync) return false
+    const permissionState = permission.ensureServerState(server())
     const [store] = serverSync.child(directory(), { bootstrap: false })
     return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
-      return !permissionState().autoResponds(item, directory())
+      return !permissionState.autoResponds(item, directory())
     })
   })
   const hasQuestions = createMemo(() => {
@@ -34,9 +34,11 @@ export function useSessionTabAvatarState(
     return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
   })
   const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
-  const unread = createMemo(
-    () => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0,
-  )
+  const notificationState = createMemo(() => {
+    if (!connection()) return
+    return notification.ensureServerState(server())
+  })
+  const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0)
   const loading = createMemo(() => {
     const serverSync = sync()
     if (!serverSync) return false

+ 20 - 2
packages/app/src/pages/layout/session-tab-avatar.tsx

@@ -19,16 +19,34 @@ export function SessionTabAvatar(props: {
     () => props.directory,
     () => props.sessionId,
   )
+  return (
+    <SessionTabAvatarView
+      project={props.project}
+      directory={props.directory}
+      revealProjectOnHover={props.revealProjectOnHover}
+      unread={state.unread()}
+      loading={state.loading()}
+    />
+  )
+}
+
+export function SessionTabAvatarView(props: {
+  project?: LocalProject
+  directory: string
+  revealProjectOnHover?: boolean
+  unread: boolean
+  loading: boolean
+}) {
   const projectAvatar = () => (
     <ProjectAvatar
       fallback={displayName(props.project ?? { worktree: props.directory })}
       src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
       variant={getProjectAvatarVariant(props.project?.icon?.color)}
-      unread={state.unread()}
+      unread={props.unread}
     />
   )
   return (
-    <Show when={state.loading()} fallback={projectAvatar()}>
+    <Show when={props.loading} fallback={projectAvatar()}>
       <span class="relative block size-4 shrink-0">
         <SessionProgressIndicatorV2
           class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}

+ 21 - 62
packages/app/src/pages/session.tsx

@@ -532,7 +532,6 @@ export default function Page() {
 
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
   const isChildSession = createMemo(() => !!info()?.parentID)
-  const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
   const canReview = createMemo(() => !!sync().project)
   const reviewTab = createMemo(() => isDesktop())
   const tabState = createSessionTabs({
@@ -690,8 +689,8 @@ export default function Page() {
       queryFn: mode
         ? () =>
             sdk()
-              .client.vcs.diff({ mode })
-              .then((result) => list(result.data))
+              .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
+              .then((result) => result.data)
               .catch((error) => {
                 console.debug("[session-review] failed to load vcs diff", { mode, error })
                 return []
@@ -738,8 +737,12 @@ export default function Page() {
           retry: 2,
           queryFn: () =>
             sdk()
-              .client.vcs.diff({ mode, directory: scope, context })
-              .then((result) => result.data ?? []),
+              .api.vcs.diff({
+                location: { directory: scope },
+                mode: mode === "git" ? "working" : mode,
+                context,
+              })
+              .then((result) => result.data),
         })
         .then((diffs) => diffs.find((diff) => diff.file === file))
 
@@ -946,10 +949,11 @@ export default function Page() {
   )
 
   const stopVcs = sdk().event.listen((evt) => {
-    if (evt.details.type !== "file.watcher.updated") return
+    const details = evt.details as { type: string; properties?: unknown }
+    if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
     const props =
-      typeof evt.details.properties === "object" && evt.details.properties
-        ? (evt.details.properties as Record<string, unknown>)
+      typeof details.properties === "object" && details.properties
+        ? (details.properties as Record<string, unknown>)
         : undefined
     const file = typeof props?.file === "string" ? props.file : undefined
     if (!file || file.startsWith(".git/")) return
@@ -1464,44 +1468,6 @@ export default function Page() {
     requestAnimationFrame(() => attempt(0))
   })
 
-  createEffect(() => {
-    const id = params.id
-    if (!id) return
-
-    if (!wantsReview()) return
-    if (sync().data.session_diff[id] !== undefined) return
-    if (sync().status === "loading") return
-
-    void sync().session.diff(id)
-  })
-
-  createEffect(
-    on(
-      () => [sessionKey(), wantsReview()] as const,
-      ([key, wants]) => {
-        if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
-        if (diffTimer !== undefined) window.clearTimeout(diffTimer)
-        diffFrame = undefined
-        diffTimer = undefined
-        if (!wants) return
-
-        const id = params.id
-        if (!id) return
-        if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
-
-        diffFrame = requestAnimationFrame(() => {
-          diffFrame = undefined
-          diffTimer = window.setTimeout(() => {
-            diffTimer = undefined
-            if (sessionKey() !== key) return
-            void sync().session.diff(id, { force: true })
-          }, 0)
-        })
-      },
-      { defer: true },
-    ),
-  )
-
   let treeDir: string | undefined
   createEffect(() => {
     const dir = sdk().directory
@@ -1757,7 +1723,7 @@ export default function Page() {
       setFollowup("failed", input.sessionID, undefined)
 
       const ok = await sendFollowupDraft({
-        client: sdk().client,
+        api: sdk().api.session,
         sync: sync(),
         serverSync: serverSync(),
         draft: item,
@@ -1853,13 +1819,13 @@ export default function Page() {
   const halt = (sessionID: string) =>
     busy(sessionID)
       ? sdk()
-          .client.session.abort({ sessionID })
+          .api.session.interrupt({ sessionID })
           .catch(() => {})
       : Promise.resolve()
 
   const revertMutation = useMutation(() => ({
     mutationFn: async (input: { sessionID: string; messageID: string }) => {
-      const client = sdk().client
+      const session = sdk().api.session
       const target = sync()
       const last = target.session.get(input.sessionID)?.revert
       const value = draft(input.messageID)
@@ -1869,10 +1835,8 @@ export default function Page() {
           roll(input.sessionID, { messageID: input.messageID }, target)
           prompt.set(value)
         },
-        request: () => halt(input.sessionID).then(() => client.session.revert(input)),
-        complete: (result) => {
-          if (result.data) merge(result.data, target)
-        },
+        request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
+        complete: () => undefined,
         rollback: () => roll(input.sessionID, last, target),
         fail,
       })
@@ -1884,7 +1848,7 @@ export default function Page() {
       const sessionID = params.id
       if (!sessionID) return
 
-      const client = sdk().client
+      const session = sdk().api.session
       const target = sync()
       const next = userMessages().find((item) => item.id > id)
       const last = target.session.get(sessionID)?.revert
@@ -1901,11 +1865,9 @@ export default function Page() {
         },
         request: () =>
           !next
-            ? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
-            : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
-        complete: (result) => {
-          if (result.data) merge(result.data, target)
-        },
+            ? halt(sessionID).then(() => session.revert.clear({ sessionID }))
+            : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
+        complete: () => undefined,
         rollback: () => roll(sessionID, last, target),
         fail,
       })
@@ -2367,9 +2329,6 @@ export default function Page() {
                     reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
                     reviewCount={reviewCount}
                     reviewPanel={reviewPanelV2}
-                    diffVersion={vcsQuery.dataUpdatedAt}
-                    loadDiff={loadReviewDiff}
-                    expandUnchanged={reviewV2State.expandMode() === "expand"}
                     reviewSidebarToggle={(disabled) => (
                       <SessionReviewV2SidebarToggle
                         opened={reviewV2State.sidebarOpened()}

+ 2 - 1
packages/app/src/pages/session/composer/session-composer-controls.ts

@@ -45,7 +45,8 @@ export function createPromptInputController(input: {
     model: {
       selection: input.model ?? local.model,
       paid: providers.paid().length > 0,
-      loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
+      loading:
+        (local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
     },
     session: {
       id: input.sessionID(),

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