Compare commits

..
Author SHA1 Message Date
Aiden Cline f45923461e chore(client): regenerate fork types with merged generator fix 2026-09-08 15:22:43 -05:00
Aiden Cline 9494aeec92 fix(plugin): keep fork history events wire-ready 2026-09-08 15:21:51 -05:00
Aiden Cline aeacdceaea feat(plugin): expose session forks with history filters 2026-09-08 15:21:51 -05:00
Aiden Cline 11aee1b49f fix(codegen): preserve equivalent suffixed type references (#48024) 2026-09-08 15:21:17 -05:00
Dax Raad a322d7f98f fix(update): bind migrated singular D1 database 2026-09-08 15:57:51 -04:00
Aiden Cline 546b9fb3c1 refactor(codemode): consolidate duplicated interpreter helpers (#48009) 2026-09-08 14:46:16 -05:00
Aiden Cline 83288921f6 fix(core): map Bedrock OpenAI reasoning effort for inference-profile model IDs (#48006) 2026-09-08 14:42:33 -05:00
opencode-agent[bot] 510b771946 chore: update nix node_modules hashes 2026-09-08 18:28:38 +00:00
Dax Raad 78915a0f17 refactor(update): rename updates service to update 2026-09-08 14:10:28 -04:00
Adam 1ead17547b fix(core): name hosted provider opencode web search (#48001) 2026-09-08 12:28:12 -05:00
Aiden Cline 0c1bf08ca6 feat(core): add Poe browser OAuth (#47883) 2026-09-08 12:15:17 -05:00
Aiden Cline 8b09f6415a refactor(codemode): export runtime value classes as Values (#48000) 2026-09-08 12:11:34 -05:00
Adam e791afdfa3 feat(core): add console web search (#47293) 2026-09-08 12:08:53 -05:00
Dax Raad e655fed6c3 fix(updates): reject retired next channel 2026-09-08 13:06:08 -04:00
ccbc018072 fix(tui): honor configured worktree strategy (#47991)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-08 12:46:57 -04:00
James Long 4432956490 fix(worktree): accept the strategy's returned directory (#47997) 2026-09-08 12:45:57 -04:00
Dax Raad 375bf4908f feat(updates): configure per-channel gradual rollouts 2026-09-08 12:34:59 -04:00
James Long cc6bff39a0 feat(tui): manage worktrees and explicitly move sessions (#47984) 2026-09-08 12:20:25 -04:00
James Long 8a5709324f fix(worktree): resolve configured paths relative to project (#47990) 2026-09-08 12:03:53 -04:00
James Long be58ca806c fix(cli): recover dev hot reloads and preserve routes (#47979) 2026-09-08 11:19:51 -04:00
Dax 9e42e5cc4c feat(core): refresh console provider config periodically (#47980) 2026-09-08 11:17:12 -04:00
opencode-agent[bot] c2a1649dd4 chore: update nix node_modules hashes 2026-09-08 14:19:25 +00:00
James Long ded9c7e505 feat(cli): add Vite-powered TUI development entrypoint (#47950) 2026-09-08 10:00:16 -04:00
f9bc2233dd fix(app): align desktop agent and model switching (#47286)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-08 18:16:08 +08:00
Simon Klee 7487999e06 tabs: add compact session tab rail (#47938) 2026-09-08 12:11:16 +02:00
Simon Klee 2eea36e731 mini: add more minimal output presets. (#47931) 2026-09-08 11:53:02 +02:00
Simon Klee 4fef8edbe8 mini: add clear-screen command (#47928) 2026-09-08 11:24:36 +02:00
Simon Klee 50c552f763 tui: add tool filtering option to Markdown exports (#47929) 2026-09-08 11:24:29 +02:00
Luke Parker a3d5923aca fix(session-ui): stop refetching missing shell output (#47926) 2026-09-08 09:12:15 +00:00
Luke Parker ea2c0184ce fix(app): release attachment blobs when no draft references them (#47922) 2026-09-08 09:11:59 +00:00
280 changed files with 6624 additions and 7040 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Deploy update service
if: github.ref_name == 'v2'
working-directory: services/updates
working-directory: services/update
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+72 -101
View File
@@ -63,7 +63,7 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
@@ -168,6 +168,7 @@
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
@@ -426,9 +427,7 @@
"@lydell/node-pty": "catalog:",
"@opencode/app": "workspace:*",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-browser-desktop": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/ui": "workspace:*",
"@sentry/solid": "catalog:",
@@ -599,12 +598,8 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@zip.js/zip.js": "2.7.62",
"electron": "42.10.1",
"solid-js": "catalog:",
"typescript": "catalog:",
"vite": "8.2.2",
"vite-plugin-solid": "2.11.14",
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
@@ -634,28 +629,6 @@
"typescript": "catalog:",
},
},
"packages/plugin-browser-desktop": {
"name": "@opencode/plugin-browser-desktop",
"version": "0.0.0",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/ui": "workspace:*",
"effect": "catalog:",
"lighthouse": "13.4.1",
"solid-js": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"devtools-protocol": "0.0.1687809",
"electron": "42.10.1",
"puppeteer-core": "25.9.0",
},
},
"packages/posts": {
"name": "@opencode/posts",
"dependencies": {
@@ -1065,8 +1038,8 @@
"wrangler": "4.110.0",
},
},
"services/updates": {
"name": "@opencode/updates",
"services/update": {
"name": "@opencode/update",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
@@ -2243,8 +2216,6 @@
"@opencode/plugin-browser": ["@opencode/plugin-browser@workspace:packages/plugin-browser"],
"@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"],
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
@@ -2275,7 +2246,7 @@
"@opencode/ui": ["@opencode/ui@workspace:packages/ui"],
"@opencode/updates": ["@opencode/updates@workspace:services/updates"],
"@opencode/update": ["@opencode/update@workspace:services/update"],
"@opencode/util": ["@opencode/util@workspace:packages/util"],
@@ -6449,11 +6420,11 @@
"@opencode/ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"@opencode/updates/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@opencode/update/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@opencode/updates/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode/update/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/update/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/web/@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
@@ -7347,15 +7318,15 @@
"@opencode/storybook/@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"@opencode/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode/update/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode/update/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode/update/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode/update/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/update/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/web/@astrojs/cloudflare/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.1", "", {}, "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ=="],
@@ -8283,71 +8254,71 @@
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode/update/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode/update/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode/update/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode/update/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode/update/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode/update/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode/update/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode/update/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode/update/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode/update/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode/update/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode/update/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode/update/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode/update/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode/web/@astrojs/cloudflare/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
@@ -8951,55 +8922,55 @@
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode/updates/wrangler/miniflare/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode/update/wrangler/miniflare/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode/web/@astrojs/cloudflare/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
@@ -9183,7 +9154,7 @@
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@opencode/web/@astrojs/cloudflare/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
}
}
+2
View File
@@ -9,6 +9,8 @@
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
-1
View File
@@ -1 +0,0 @@
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
-81
View File
@@ -1,81 +0,0 @@
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { Plugin } from "@opencode/plugin/desktop"
import { Panel } from "@opencode/plugin/desktop/solid"
import { Button } from "@opencode/ui/button"
import { TextInput } from "@opencode/ui/text-input"
import { Stack, Text } from "@opencode/ui/layout"
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { base64Encode } from "@opencode/util/encode"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
const plugin = Plugin.define({
id: "test.panels",
setup(ctx) {
const [state, setState] = createStore({ available: true, closed: 0 })
const [draft, saveDraft] = ctx.storage.memory("draft", { initial: { text: "" } })
ctx.ui.slot({
append: "titlebar.actions",
render: () => (
<>
<Button
onClick={() => {
const session = ctx.sessions.current()
if (session) ctx.ui.panel.open("notes", session)
}}
>
Open notes
</Button>
<Button onClick={() => setState("available", !state.available)}>Toggle contribution</Button>
</>
),
})
ctx.ui.slot({
append: "session.panel",
when: () => state.available,
render: () => (
<>
<Panel id="notes" title="Notes" onClose={() => setState("closed", (count) => count + 1)}>
<Stack padding="medium">
<TextInput
aria-label="Notes draft"
value={draft.text}
onInput={(event) =>
saveDraft((draft) => {
draft.text = event.currentTarget.value
})
}
/>
</Stack>
</Panel>
<Panel id="results" title="Results">
<Stack padding="medium">
<Text>Closed notes: {state.closed}</Text>
</Stack>
</Panel>
</>
),
})
},
})
const web = createWebPlatform("test")
const history = createMemoryHistory()
history.set({ value: `/server/${base64Encode(web.currentServerUrl!)}/session/${fixture.sourceID}` })
render(
() => (
<PlatformProvider value={{ ...web.platform, extensionPlugins: [plugin] }}>
<AppBaseProviders>
<AppInterface
servers={[{ type: "http", http: { url: web.currentServerUrl! } }]}
defaultServer={ServerConnection.Key.make(web.currentServerUrl!)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</AppBaseProviders>
</PlatformProvider>
),
document.getElementById("root")!,
)
@@ -1 +0,0 @@
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./manager-fixture.tsx"></script></body></html>
@@ -1,53 +0,0 @@
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
import { render } from "solid-js/web"
import { Schema } from "effect"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
const web = createWebPlatform("test")
const history = createMemoryHistory()
history.set({ value: "/settings" })
const inventory = Schema.Array(ExtensionManager.Installed)
async function request(name: string, body?: BodyInit) {
const response = await fetch(`/__desktop-extensions/${name}`, { method: body ? "POST" : "GET", body })
const value: unknown = await response.json()
if (!response.ok)
throw new ExtensionManager.ManagerError(Schema.decodeUnknownSync(ExtensionManager.Failure)(value).code)
return value
}
const manager: ExtensionManager.Transport = {
list: async () => Schema.decodeUnknownSync(inventory)(await request("list")),
install: async (data) =>
Schema.decodeUnknownSync(inventory)(await request("install", data as Uint8Array<ArrayBuffer>)),
installURL: async (url) => Schema.decodeUnknownSync(inventory)(await request("url", JSON.stringify({ url }))),
enable: async (id, enabled) =>
Schema.decodeUnknownSync(inventory)(await request("enable", JSON.stringify({ id, enabled }))),
reload: async (id) => Schema.decodeUnknownSync(inventory)(await request("reload", JSON.stringify({ id }))),
source: async (id, revision) =>
Schema.decodeUnknownSync(ExtensionManager.Source)(await request("source", JSON.stringify({ id, revision }))),
assetURL: (id, revision, path) => `/__desktop-extensions/assets/${id}/${revision}/${path}`,
onChange(callback) {
const listener = (event: Event) => {
if (event instanceof CustomEvent) callback(Schema.decodeUnknownSync(inventory)(event.detail))
}
window.addEventListener("test-extensions-changed", listener)
return () => window.removeEventListener("test-extensions-changed", listener)
},
}
render(
() => (
<PlatformProvider value={{ ...web.platform, extensionManager: manager }}>
<AppBaseProviders>
<AppInterface
servers={[{ type: "http", http: { url: web.currentServerUrl! } }]}
defaultServer={ServerConnection.Key.make(web.currentServerUrl!)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</AppBaseProviders>
</PlatformProvider>
),
document.getElementById("root")!,
)
-264
View File
@@ -1,264 +0,0 @@
import { expect, test } from "@playwright/test"
import { Schema } from "effect"
import { createServer } from "node:http"
import { once } from "node:events"
import { openDatabase } from "../../../desktop/src/main/storage/database"
import { createExtensionManager, readExtensionAsset } from "../../../desktop/src/main/extensions/manager"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { extensionArchive } from "../../../desktop/test/extensions/fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
const renderer = (version: string) => `
const { usePlugin } = require('@opencode/plugin/desktop');
const { createEffect, createComponent } = require('solid-js');
const { Panel } = require('@opencode/plugin/desktop/solid');
const { Schema } = require('effect');
module.exports.default = { id: 'test.lifecycle', setup(ctx) {
const [documentState, setDocument, ready] = ctx.storage.persist('document', Schema.Struct({ text: Schema.String }), { text: 'empty' }, { legacyKey: 'extension-fixture-legacy' });
const [state, update] = ctx.storage.memory('counts', { initial: { starts: 0, stops: 0 } });
update(state => state.starts++);
ctx.lifecycle.own(() => update(state => state.stops++));
const marker = (id) => {
const output = document.createElement('output'); output.hidden = true; output.dataset.testid = id;
createEffect(() => output.textContent = ${JSON.stringify(version)} + ':' + state.starts + ':' + state.stops);
return output;
};
ctx.commands.register(() => [
{ id: 'show', title: 'Show extension fixture', bind: 'mod+shift+y', run() { const session = ctx.sessions.current(); if (session) ctx.ui.panel.open('state', session) } },
{ id: 'save', title: 'Save extension fixture', bind: 'mod+shift+x', run() { setDocument('text', 'saved') } },
]);
ctx.ui.slot({ append: 'session.panel', render: () => createComponent(Panel, { id: 'state', title: 'File utilities', group: 'state', get children() { return marker('extension-panel-lifecycle') } }) });
ctx.ui.slot({ append: 'app', render() {
if (usePlugin().lifecycle.signal !== ctx.lifecycle.signal) throw new Error('Shared plugin context identity was lost');
const stored = document.createElement('output'); stored.hidden = true; stored.dataset.testid = 'extension-persisted';
createEffect(() => stored.textContent = ready() ? documentState.text : 'loading');
return [marker('extension-lifecycle'), stored];
} });
} };`
const payload = Schema.Struct({
id: Schema.optionalKey(Schema.String),
revision: Schema.optionalKey(Schema.String),
enabled: Schema.optionalKey(Schema.Boolean),
url: Schema.optionalKey(Schema.String),
})
for (const direction of ["ltr", "rtl"] as const)
test(`manager installs, hot-replaces, reloads, and disables across windows in ${direction}`, async ({
page,
context,
}, info) => {
const database = openDatabase(":memory:")
const manager = createExtensionManager({
db: database.db,
fetch,
changed(_id, entries) {
context.pages().forEach((page) => {
void page.evaluate(
(entries) => window.dispatchEvent(new CustomEvent("test-extensions-changed", { detail: entries })),
entries,
)
})
},
})
const archive = async (version: string, code = renderer(version)) =>
extensionArchive({
version,
renderer: code,
files: { "assets/style.css": ":root { --installed-extension-marker: 1; }" },
manifest: {
imports: ["@opencode/plugin/desktop", "@opencode/plugin/desktop/solid", "solid-js", "effect"],
style: "assets/style.css",
},
})
const first = await archive("1.0.0")
const http = createServer((_request, response) => {
response.setHeader("Content-Type", "application/vnd.ocdx")
response.end(first)
})
http.listen(0, "127.0.0.1")
await once(http, "listening")
const address = http.address()
if (!address || typeof address === "string") throw new Error("Fixture address is unavailable")
await context.route("**/__desktop-extensions/**", async (route) => {
const path = new URL(route.request().url()).pathname.split("/").slice(2)
try {
if (path[0] === "assets") {
const bytes = readExtensionAsset(database.db, path[1], path[2], path.slice(3).join("/"))
await route.fulfill({ status: bytes ? 200 : 404, body: bytes, contentType: "text/css" })
return
}
const data = Schema.decodeUnknownSync(payload)(
JSON.parse(path[0] === "install" ? "{}" : (route.request().postData() ?? "{}")),
)
const result =
path[0] === "install"
? await manager.install(route.request().postDataBuffer()!)
: path[0] === "url"
? await manager.installURL(data.url ?? "")
: path[0] === "enable"
? manager.enable(data.id ?? "", data.enabled ?? false)
: path[0] === "reload"
? manager.reload(data.id ?? "")
: path[0] === "source"
? manager.source(data.id ?? "", data.revision)
: manager.list()
await route.fulfill({ json: result })
} catch (error) {
await route.fulfill({
status: 400,
json: { code: error instanceof ExtensionManager.ManagerError ? error.code : "storage" },
})
}
})
const open = async (target: typeof page) => {
await mockOpenCodeServer(target, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(target)
await target.addInitScript(() =>
localStorage.setItem("opencode.global.dat:extension-fixture-legacy", JSON.stringify({ text: "legacy" })),
)
await target.goto("/e2e/extensions/manager-fixture.html")
await target.getByTestId("settings-screen").getByRole("tab", { name: "Extensions", exact: true }).click()
await expect(target.getByRole("heading", { name: "Install extensions", exact: true })).toBeVisible()
const settings = target.getByTestId("settings-screen")
const navigation = settings.locator(".settings-nav")
await expect(
navigation
.locator('[data-slot="settings-nav-group"]')
.filter({ has: target.getByRole("tab", { name: "Extensions", exact: true }) })
.getByRole("tab"),
).toHaveText(["Extensions", "Experimental"])
await expect(
navigation
.locator('[data-slot="settings-nav-group"]')
.filter({ has: target.getByRole("tab", { name: "Tools", exact: true }) })
.getByRole("tab"),
).toHaveText(["Providers", "Models", "Tools"])
await expect(settings.getByRole("tab", { name: "Desktop", exact: true })).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Skills", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Install extensions", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await target.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
}
try {
await open(page)
await page.setViewportSize({ width: 640, height: 900 })
await page.getByRole("button", { name: "Extensions", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Tools", exact: true }).click()
await expect(page.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Tools", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Extensions", exact: true }).click()
await expect(page.getByRole("heading", { name: "Install extensions", exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 720 })
await expect(page.locator('[data-component="desktop-extension-manager"]').getByRole("status")).toHaveCount(0)
await page.screenshot({ path: info.outputPath("extensions-manager-empty.png") })
await page
.getByRole("textbox", { name: "Extension URL", exact: true })
.fill(`http://127.0.0.1:${address.port}/extension.ocdx`)
await page.getByRole("textbox", { name: "Extension URL", exact: true }).press("Enter")
const enabled = page.getByRole("switch", { name: "Enable File utilities", exact: true })
await expect(enabled).toBeChecked()
await expect(page.getByTestId("extension-lifecycle")).toHaveText("1.0.0:1:0")
await expect(page.getByTestId("extension-persisted")).toHaveText("legacy")
await page.keyboard.press("Control+Shift+x")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await page.screenshot({ path: info.outputPath("extensions-manager.png") })
await page.getByRole("button", { name: "Reload File utilities", exact: true }).click()
await expect(page.getByTestId("extension-lifecycle")).toHaveText("1.0.0:2:1")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(1)
const second = await context.newPage()
await open(second)
await expect(second.getByTestId("extension-lifecycle")).toHaveText("1.0.0:1:0")
await expect(second.getByTestId("extension-persisted")).toHaveText("saved")
await second.locator('header a[href$="/ses_smoke_source"]').click()
await expect(second.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await expect(second.getByRole("button", { name: "Toggle review", exact: true })).toBeEnabled()
await second.keyboard.press("Control+Shift+y")
await expect(second.getByTestId("extension-panel-lifecycle")).toHaveText("1.0.0:1:0")
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
name: "update.ocdx",
mimeType: "application/vnd.ocdx",
buffer: Buffer.from(await archive("2.0.0")),
})
await expect(page.getByTestId("extension-lifecycle")).toHaveText("2.0.0:3:2")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await expect(second.getByTestId("extension-lifecycle")).toHaveText("2.0.0:2:1")
await expect(second.getByTestId("extension-panel-lifecycle")).toHaveText("2.0.0:2:1")
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
name: "broken.ocdx",
mimeType: "application/vnd.ocdx",
buffer: Buffer.from(
await archive(
"3.0.0",
`module.exports.default = { id: 'test.lifecycle', setup(ctx) { ctx.ui.slot({ append: 'app', render() { const node = document.createElement('output'); node.hidden = true; node.dataset.testid = 'partial-load'; return node } }); throw new Error('broken update') } }`,
),
),
})
await expect(page.getByRole("alert")).toHaveText("Unable to activate File utilities.")
await expect(page.getByTestId("extension-lifecycle")).toHaveText("2.0.0:3:2")
await expect(page.getByTestId("partial-load")).toHaveCount(0)
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(1)
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
name: "fixed.ocdx",
mimeType: "application/vnd.ocdx",
buffer: Buffer.from(await archive("4.0.0")),
})
await expect(page.getByTestId("extension-lifecycle")).toHaveText("4.0.0:4:3")
await second.keyboard.press("Control+,")
await second.getByTestId("settings-screen").getByRole("tab", { name: "Extensions", exact: true }).click()
await page
.locator('[data-component="settings-row"]')
.filter({ has: enabled })
.locator('[data-slot="switch-control"]')
.click()
await expect(second.getByRole("switch", { name: "Enable File utilities", exact: true })).not.toBeChecked()
await expect(page.getByTestId("extension-lifecycle")).toHaveCount(0)
await expect(page.getByTestId("extension-persisted")).toHaveCount(0)
await expect(second.getByTestId("extension-lifecycle")).toHaveCount(0)
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(0)
await enabled.press("Space")
await expect(page.getByTestId("extension-lifecycle")).toHaveText("4.0.0:5:4")
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
await expect(second.getByTestId("extension-lifecycle")).toHaveText("4.0.0:4:3")
const archives = await Promise.all(
["Text helpers", "Path helpers"].map(async (name, index) => ({
name: `helper-${index}.ocdx`,
bytes: Buffer.from(await extensionArchive({ id: `test.helper-${index}`, manifest: { name } })).toString(
"base64",
),
})),
)
const drop = await page.evaluateHandle((archives) => {
const transfer = new DataTransfer()
archives.forEach((archive) =>
transfer.items.add(
new File([Uint8Array.from(atob(archive.bytes), (char) => char.charCodeAt(0))], archive.name),
),
)
return transfer
}, archives)
await page.locator(".desktop-extension-drop").dispatchEvent("drop", { dataTransfer: drop })
await expect(page.getByRole("switch", { name: "Enable Text helpers", exact: true })).toBeChecked()
await expect(second.getByRole("switch", { name: "Enable Path helpers", exact: true })).toBeChecked()
await drop.dispose()
await second.close()
} finally {
http.close()
database.close()
}
})
@@ -1,48 +0,0 @@
import { test, expect } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
test("independent panel instances retain drafts, close explicitly, and obey plugin availability", async ({
page,
}, info) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.goto("/e2e/extensions/fixture.html")
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await page.getByRole("textbox", { name: "Notes draft", exact: true }).fill("Retained draft")
await page.getByRole("tab", { name: "Results", exact: true }).click()
await expect(page.getByText("Closed notes: 0", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Notes", exact: true }).click()
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
await page
.locator('[data-slot="tabs-trigger-wrapper"][data-value="extension:test.panels:notes"]')
.getByRole("button", { name: "Close tab", exact: true })
.click()
await page.getByRole("tab", { name: "Results", exact: true }).click()
await expect(page.getByText("Closed notes: 1", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
await page.screenshot({ path: info.outputPath("extension-panels.png") })
await page
.getByRole("tab", { name: "Results", exact: true })
.dragTo(page.getByRole("tab", { name: "Notes", exact: true }))
await expect(page.getByRole("tab", { name: /^(Notes|Results)$/ })).toHaveText(["Results", "Notes"])
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('header a[href$="/ses_smoke_source"]').click()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await expect(page.getByRole("tab", { name: /^(Notes|Results)$/ })).toHaveText(["Results", "Notes"])
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
await page.getByRole("button", { name: "Toggle contribution", exact: true }).click()
await expect(page.getByRole("tab", { name: "Notes", exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Toggle contribution", exact: true }).click()
await page.getByRole("button", { name: "Open notes", exact: true }).click()
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
})
@@ -1,26 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1280, height: 720 }, serviceWorkers: "block" })
test("server tools remain available without a native extension manager", async ({ page }, info) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Skills", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toHaveCount(0)
await page.screenshot({ path: info.outputPath("tools-settings.png") })
})
@@ -28,9 +28,7 @@ resizing reduces the effective row capacity. The report records actual retained
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
requires the final marker in Ghostty and completion of its write callbacks, not
just WebSocket delivery. Teardown requires Home readiness and the final serialized
snapshot, capped at the production limit of 2,000 scrollback rows plus the screen.
The snapshot must contain the same ordered workload records as that retained tail.
Input, focus, resizing, and native process survival are checked.
snapshot. Input, focus, resizing, and native process survival are checked.
`probe.ts` is included only by this benchmark build. It observes actual writes,
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
@@ -236,15 +236,6 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`)
await expect(close).toBeVisible()
const cpuBefore = await cdp.send("Performance.getMetrics")
const retained = await page.evaluate(() => {
const term = window.terminalProbe.term!
const buffer = term.buffer.normal
const start = Math.max(0, buffer.length - term.rows - 2_000)
return Array.from(
{ length: buffer.length - start },
(_, index) => buffer.getLine(start + index)?.translateToString(true) ?? "",
).join("\n")
})
const start = await page.evaluate(() => performance.now())
await close.click()
await expect(page).toHaveURL("/")
@@ -265,11 +256,9 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) *
1000
const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value)
const records = (text: string) => Array.from(text.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))
const expected = records(retained)
const actual = records(snapshot)
expect(actual.length).toBe(expected.length)
expect(actual.every((record, index) => record === expected[index])).toBe(true)
expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual(
Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index),
)
expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE")
await writeFile(
path.join(
@@ -312,7 +301,6 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
await expect
.poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols)))
.toBe(true)
await expect(page.locator('#terminal-panel [data-slot="tabs-list"]')).toHaveCSS("padding-inline-start", "12px")
expect(closed).toBe(0)
}
if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") {
@@ -1,6 +1,25 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
test("serves an empty config document list for composer defaults", async () => {
const server = createMockServerHandler({
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
})
try {
const response = await server.handler(
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
} finally {
await server.dispose()
}
})
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -342,6 +342,7 @@ async function mockServers(
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -169,7 +169,7 @@ test("worktree deletion sends the project location separately from the target",
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
test("tools opens without waiting for MCPs", async ({ page }) => {
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
@@ -179,9 +179,9 @@ test("tools opens without waiting for MCPs", async ({ page }) => {
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
@@ -55,7 +55,7 @@ for (const viewport of [
"Worktrees",
"Providers",
"Models",
"Tools",
"Extensions",
"Experimental",
"About",
]) {
@@ -602,6 +602,7 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -206,9 +206,9 @@ test("animates review and terminal panels while caching hidden terminal content"
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expectAnimation(page, "auxiliary-panel-size-in")
await expectAnimation(page, "terminal-panel-size-in")
await page.keyboard.press("Control+Backquote")
await expectAnimation(page, "auxiliary-panel-size-out")
await expectAnimation(page, "terminal-panel-size-out")
await expect(panel).toBeHidden()
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
})
+1
View File
@@ -39,6 +39,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
+1
View File
@@ -207,6 +207,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
reference: () =>
Effect.succeed({
location: {
+1 -1
View File
@@ -64,8 +64,8 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/session-ui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
@@ -1,49 +0,0 @@
import path from "node:path"
const packages = path.resolve(import.meta.dirname, "../..")
const imports = new Set([
"solid-js",
"solid-js/store",
"solid-js/web",
"@tanstack/solid-query",
"effect",
"@opencode/plugin/desktop",
"@opencode/plugin/desktop/solid",
"@opencode/plugin/desktop/manager",
"@opencode/plugin/desktop/persistence",
"@opencode/schema/rpc",
"@opencode/client",
"@opencode/client/solid",
"@opencode/util/encode",
"@opencode/util/path",
])
for (const name of ["ui", "session-ui"]) {
const directory = path.join(packages, name)
const manifest: { name: string; exports: Record<string, string> } = await Bun.file(
path.join(directory, "package.json"),
).json()
for (const [key, target] of Object.entries(manifest.exports)) {
if (!/\.tsx?$/.test(target) || /storybook/.test(key)) continue
if (!key.includes("*")) {
imports.add(key === "." ? manifest.name : manifest.name + key.slice(1))
continue
}
const [prefix, suffix] = target.split("*")
for await (const file of new Bun.Glob(target.slice(2)).scan(directory)) {
if (/(\.test\.|\.stories\.|\.story\.)/.test(file)) continue
imports.add(
manifest.name +
key.slice(1).replace("*", ("./" + file.replaceAll("\\", "/")).slice(prefix.length, -suffix.length)),
)
}
}
}
await Bun.write(
path.join(packages, "app/src/extensions/modules.gen.ts"),
`// Generated by script/generate-extension-modules.ts. Shared renderer modules only.\nexport const modules: Record<string, () => Promise<unknown>> = {\n${Array.from(
imports,
)
.sort()
.map((name) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(name)}),`)
.join("\n")}\n}\n`,
)
+68 -38
View File
@@ -1,4 +1,5 @@
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
@@ -9,6 +10,7 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
@@ -31,6 +33,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
@@ -43,15 +46,31 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
}
export function createComposerModelSelection(input: {
agent: () => { model?: ModelKey; variant?: string } | undefined
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const local = useLocal()
const prompt = useComposerState()
const configuredModel = useConfiguredModel()
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
createEffect(
on(
() => input.agent()?.name,
(name, previous) => {
if (!name || !previous || name === previous) return
batch(() => {
const model = prompt.model.current()
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
})
},
),
)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
@@ -62,7 +81,8 @@ export function createComposerModelSelection(input: {
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
if (!configuredModel.ready()) return
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
@@ -74,7 +94,9 @@ export function createComposerModelSelection(input: {
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
trackSessionCommit: local.model.trackSessionCommit,
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent: recentModels,
list: models.list,
@@ -83,19 +105,24 @@ export function createComposerModelSelection(input: {
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
const next =
items[
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
batch(() => {
if (item && !valid(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
prompt.model.set(
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
})
},
visible: models.visible,
setVisibility: models.setVisibility,
@@ -104,38 +131,41 @@ export function createComposerModelSelection(input: {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
},
selected() {
return prompt.model.current()?.variant
const model = prompt.model.current()
return model && valid(model) ? model.variant : undefined
},
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const variants = this.list()
@@ -143,13 +173,13 @@ export function createComposerModelSelection(input: {
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
},
} satisfies ModelSelection
}
return selection
return selection satisfies ModelSelection
}
+152 -6
View File
@@ -91,6 +91,8 @@ function session(input: {
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
return {
id: "session-1",
@@ -99,12 +101,16 @@ function session(input: {
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
switchAgent:
input.switchAgent ??
(async () => {
input.calls.push("switch-agent")
}),
switchModel:
input.switchModel ??
(async () => {
input.calls.push("switch-model")
}),
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
@@ -122,6 +128,146 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const agent = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
prompt: async () => {
throw new Error("command must not call prompt")
},
switchAgent: async (request) => {
expect(request.agent).toBe("build")
calls.push("agent")
started.resolve()
await agent.promise
},
switchModel: async (request) => {
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
calls.push("model")
await committed.promise
},
command: async (request) => {
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
expect(request).not.toHaveProperty("model")
expect(request).not.toHaveProperty("agent")
calls.push("command")
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
expect(value).toEqual({
agent: "build",
model: { providerID: "provider-1", modelID: "model-1" },
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
await started.promise
expect(calls).toEqual(["track", "agent"])
selected.agents.current = "plan"
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
agent.resolve()
committed.resolve()
await completed.promise
expect(calls).toEqual(["track", "agent", "model", "command"])
})
test("commits the model even when cached session state already matches", async () => {
const state = createMemoryComposerState({ prompt: "continue" }).capture()
const calls: string[] = []
const done = Promise.withResolvers<void>()
const target = session({
calls,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
prompt: async () => done.resolve(),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await done.promise
expect(calls).toEqual(["switch-model", "prompt"])
})
test("cancels selection tracking and does not execute a command when selection fails", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
prompt: async () => {
calls.push("prompt")
},
switchModel: async () => {
throw error
},
command: async () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
expect(await failed.promise).toBe(error)
expect(calls).toEqual(["track", "switch-agent", "cancel"])
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
+38 -23
View File
@@ -11,6 +11,7 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -88,7 +89,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -124,9 +125,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -315,8 +319,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -328,7 +334,33 @@ async function sendCommand(
})
}
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
})
} catch (error) {
cancel?.()
throw error
}
}
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
@@ -336,24 +368,7 @@ async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
await applySelection(session, value.selection, track)
}
const admission = {
+10
View File
@@ -4,6 +4,16 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
-56
View File
@@ -1,56 +0,0 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { RegisteredPanel } from "./provider"
/** Grouped content stays mounted while any declaration in its group is available. */
export function ExtensionPanelContent(props: { panels: readonly RegisteredPanel[]; active: string | undefined }) {
const selected = createMemo(() => props.panels.find((panel) => panel.key === props.active))
const groups = createMemo(() => Array.from(new Set(props.panels.filter((panel) => panel.props.group).map(groupKey))))
const single = createMemo(() => {
const panel = selected()
return panel && !panel.props.group ? panel : undefined
})
return (
<>
<For each={groups()}>
{(key) => {
const [mounted, setMounted] = createSignal(false)
const active = () => !!selected() && groupKey(selected()!) === key
const declaration = props.panels.find((panel) => groupKey(panel) === key)!
createEffect(() => {
if (active()) setMounted(true)
})
return (
<Show when={mounted()}>
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
classList={{ hidden: !active() }}
inert={!active()}
aria-label={selected()?.props.title}
>
{declaration.render()}
</div>
</Show>
)
}}
</For>
<Show when={single()} keyed>
{(panel) => (
<div
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden flex flex-col"
aria-label={panel.props.title}
>
{panel.render()}
</div>
)}
</Show>
</>
)
}
function groupKey(panel: RegisteredPanel) {
return `${panel.session.key}/${panel.plugin}/${panel.generation}/${panel.props.group ?? panel.key}`
}
-2
View File
@@ -1,2 +0,0 @@
export const extensionTabKey = (plugin: string, id: string) => `extension:${plugin}:${id}`
export const isExtensionTab = (id: string | undefined) => !!id?.startsWith("extension:")
-36
View File
@@ -1,36 +0,0 @@
import { Schema } from "effect"
import { Plugin } from "@opencode/plugin/desktop"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { evaluateBundle } from "@opencode/plugin/desktop/bundle"
export async function loadExtension(input: ExtensionManager.Source) {
const { modules } = await import("./modules.gen")
const shared = new Map(
await Promise.all(
input.manifest.imports.map(async (name) => {
const load = modules[name]
if (!load) throw new ExtensionManager.ManagerError("invalidModule")
return [name, await load()] as const
}),
),
)
const value = Schema.decodeUnknownOption(Schema.Struct({ default: Plugin.Definition }))(
evaluateBundle(input.source, shared),
)
if (value._tag === "None" || value.value.default.id !== input.manifest.id)
throw new ExtensionManager.ManagerError("invalidModule")
const definition = value.value.default
return {
...definition,
setup(context) {
if (input.manifest.style) {
const stylesheet = document.createElement("link")
stylesheet.rel = "stylesheet"
stylesheet.href = context.assets.url(input.manifest.style)
document.head.append(stylesheet)
context.lifecycle.own(() => stylesheet.remove())
}
return definition.setup(context)
},
} satisfies Plugin.Definition
}
-216
View File
@@ -1,216 +0,0 @@
// Generated by script/generate-extension-modules.ts. Shared renderer modules only.
export const modules: Record<string, () => Promise<unknown>> = {
"@opencode/client": () => import("@opencode/client"),
"@opencode/client/solid": () => import("@opencode/client/solid"),
"@opencode/plugin/desktop": () => import("@opencode/plugin/desktop"),
"@opencode/plugin/desktop/manager": () => import("@opencode/plugin/desktop/manager"),
"@opencode/plugin/desktop/persistence": () => import("@opencode/plugin/desktop/persistence"),
"@opencode/plugin/desktop/solid": () => import("@opencode/plugin/desktop/solid"),
"@opencode/schema/rpc": () => import("@opencode/schema/rpc"),
"@opencode/session-ui/actions": () => import("@opencode/session-ui/actions"),
"@opencode/session-ui/attachment-card": () => import("@opencode/session-ui/attachment-card"),
"@opencode/session-ui/basic-tool": () => import("@opencode/session-ui/basic-tool"),
"@opencode/session-ui/comment-card": () => import("@opencode/session-ui/comment-card"),
"@opencode/session-ui/context": () => import("@opencode/session-ui/context"),
"@opencode/session-ui/context/data": () => import("@opencode/session-ui/context/data"),
"@opencode/session-ui/context/markdown": () => import("@opencode/session-ui/context/markdown"),
"@opencode/session-ui/dock-prompt": () => import("@opencode/session-ui/dock-prompt"),
"@opencode/session-ui/document": () => import("@opencode/session-ui/document"),
"@opencode/session-ui/file": () => import("@opencode/session-ui/file"),
"@opencode/session-ui/file-media": () => import("@opencode/session-ui/file-media"),
"@opencode/session-ui/file-search": () => import("@opencode/session-ui/file-search"),
"@opencode/session-ui/file-ssr": () => import("@opencode/session-ui/file-ssr"),
"@opencode/session-ui/line-comment": () => import("@opencode/session-ui/line-comment"),
"@opencode/session-ui/line-comment-annotations": () => import("@opencode/session-ui/line-comment-annotations"),
"@opencode/session-ui/line-comment-styles": () => import("@opencode/session-ui/line-comment-styles"),
"@opencode/session-ui/markdown": () => import("@opencode/session-ui/markdown"),
"@opencode/session-ui/markdown-cache": () => import("@opencode/session-ui/markdown-cache"),
"@opencode/session-ui/markdown-stream": () => import("@opencode/session-ui/markdown-stream"),
"@opencode/session-ui/message": () => import("@opencode/session-ui/message"),
"@opencode/session-ui/message-file": () => import("@opencode/session-ui/message-file"),
"@opencode/session-ui/message-nav": () => import("@opencode/session-ui/message-nav"),
"@opencode/session-ui/message-part": () => import("@opencode/session-ui/message-part"),
"@opencode/session-ui/pierre": () => import("@opencode/session-ui/pierre"),
"@opencode/session-ui/pierre/comment-hover": () => import("@opencode/session-ui/pierre/comment-hover"),
"@opencode/session-ui/pierre/commented-lines": () => import("@opencode/session-ui/pierre/commented-lines"),
"@opencode/session-ui/pierre/diff-selection": () => import("@opencode/session-ui/pierre/diff-selection"),
"@opencode/session-ui/pierre/file-find": () => import("@opencode/session-ui/pierre/file-find"),
"@opencode/session-ui/pierre/file-runtime": () => import("@opencode/session-ui/pierre/file-runtime"),
"@opencode/session-ui/pierre/file-selection": () => import("@opencode/session-ui/pierre/file-selection"),
"@opencode/session-ui/pierre/index": () => import("@opencode/session-ui/pierre/index"),
"@opencode/session-ui/pierre/media": () => import("@opencode/session-ui/pierre/media"),
"@opencode/session-ui/pierre/selection-bridge": () => import("@opencode/session-ui/pierre/selection-bridge"),
"@opencode/session-ui/pierre/virtualizer": () => import("@opencode/session-ui/pierre/virtualizer"),
"@opencode/session-ui/pierre/worker": () => import("@opencode/session-ui/pierre/worker"),
"@opencode/session-ui/session-diff": () => import("@opencode/session-ui/session-diff"),
"@opencode/session-ui/session-retry": () => import("@opencode/session-ui/session-retry"),
"@opencode/session-ui/session-review": () => import("@opencode/session-ui/session-review"),
"@opencode/session-ui/timeline": () => import("@opencode/session-ui/timeline"),
"@opencode/session-ui/timeline/detail": () => import("@opencode/session-ui/timeline/detail"),
"@opencode/session-ui/timeline/projection": () => import("@opencode/session-ui/timeline/projection"),
"@opencode/session-ui/timeline/row": () => import("@opencode/session-ui/timeline/row"),
"@opencode/session-ui/tool-count-label": () => import("@opencode/session-ui/tool-count-label"),
"@opencode/session-ui/tool-count-summary": () => import("@opencode/session-ui/tool-count-summary"),
"@opencode/session-ui/tool-error-card": () => import("@opencode/session-ui/tool-error-card"),
"@opencode/session-ui/tool-status-title": () => import("@opencode/session-ui/tool-status-title"),
"@opencode/session-ui/v2/line-comment-annotations-v2": () => import("@opencode/session-ui/v2/line-comment-annotations-v2"),
"@opencode/session-ui/v2/session-file-panel-v2": () => import("@opencode/session-ui/v2/session-file-panel-v2"),
"@opencode/session-ui/v2/session-progress-indicator-v2": () => import("@opencode/session-ui/v2/session-progress-indicator-v2"),
"@opencode/session-ui/v2/session-review-empty-changes-v2": () => import("@opencode/session-ui/v2/session-review-empty-changes-v2"),
"@opencode/session-ui/v2/session-review-empty-no-git-v2": () => import("@opencode/session-ui/v2/session-review-empty-no-git-v2"),
"@opencode/session-ui/v2/session-review-file-preview-v2": () => import("@opencode/session-ui/v2/session-review-file-preview-v2"),
"@opencode/session-ui/v2/session-review-v2": () => import("@opencode/session-ui/v2/session-review-v2"),
"@opencode/ui/accordion": () => import("@opencode/ui/accordion"),
"@opencode/ui/animated-number": () => import("@opencode/ui/animated-number"),
"@opencode/ui/app-icon": () => import("@opencode/ui/app-icon"),
"@opencode/ui/auxiliary-panel": () => import("@opencode/ui/auxiliary-panel"),
"@opencode/ui/avatar": () => import("@opencode/ui/avatar"),
"@opencode/ui/badge": () => import("@opencode/ui/badge"),
"@opencode/ui/button": () => import("@opencode/ui/button"),
"@opencode/ui/card": () => import("@opencode/ui/card"),
"@opencode/ui/checkbox": () => import("@opencode/ui/checkbox"),
"@opencode/ui/collapsible": () => import("@opencode/ui/collapsible"),
"@opencode/ui/context": () => import("@opencode/ui/context"),
"@opencode/ui/context-menu": () => import("@opencode/ui/context-menu"),
"@opencode/ui/context/dialog": () => import("@opencode/ui/context/dialog"),
"@opencode/ui/context/file": () => import("@opencode/ui/context/file"),
"@opencode/ui/context/helper": () => import("@opencode/ui/context/helper"),
"@opencode/ui/context/i18n": () => import("@opencode/ui/context/i18n"),
"@opencode/ui/context/marked": () => import("@opencode/ui/context/marked"),
"@opencode/ui/context/marked-base": () => import("@opencode/ui/context/marked-base"),
"@opencode/ui/context/marked-parser": () => import("@opencode/ui/context/marked-parser"),
"@opencode/ui/context/marked-theme": () => import("@opencode/ui/context/marked-theme"),
"@opencode/ui/context/marked-theme-register": () => import("@opencode/ui/context/marked-theme-register"),
"@opencode/ui/context/worker-pool": () => import("@opencode/ui/context/worker-pool"),
"@opencode/ui/dialog": () => import("@opencode/ui/dialog"),
"@opencode/ui/diff-changes": () => import("@opencode/ui/diff-changes"),
"@opencode/ui/divider": () => import("@opencode/ui/divider"),
"@opencode/ui/dock-surface": () => import("@opencode/ui/dock-surface"),
"@opencode/ui/favicon": () => import("@opencode/ui/favicon"),
"@opencode/ui/field": () => import("@opencode/ui/field"),
"@opencode/ui/file-icon": () => import("@opencode/ui/file-icon"),
"@opencode/ui/file-tree-item": () => import("@opencode/ui/file-tree-item"),
"@opencode/ui/font": () => import("@opencode/ui/font"),
"@opencode/ui/hooks": () => import("@opencode/ui/hooks"),
"@opencode/ui/hover-card": () => import("@opencode/ui/hover-card"),
"@opencode/ui/i18n/am": () => import("@opencode/ui/i18n/am"),
"@opencode/ui/i18n/ar": () => import("@opencode/ui/i18n/ar"),
"@opencode/ui/i18n/az": () => import("@opencode/ui/i18n/az"),
"@opencode/ui/i18n/bg": () => import("@opencode/ui/i18n/bg"),
"@opencode/ui/i18n/bn": () => import("@opencode/ui/i18n/bn"),
"@opencode/ui/i18n/br": () => import("@opencode/ui/i18n/br"),
"@opencode/ui/i18n/bs": () => import("@opencode/ui/i18n/bs"),
"@opencode/ui/i18n/ca": () => import("@opencode/ui/i18n/ca"),
"@opencode/ui/i18n/cs": () => import("@opencode/ui/i18n/cs"),
"@opencode/ui/i18n/da": () => import("@opencode/ui/i18n/da"),
"@opencode/ui/i18n/de": () => import("@opencode/ui/i18n/de"),
"@opencode/ui/i18n/dv": () => import("@opencode/ui/i18n/dv"),
"@opencode/ui/i18n/dz": () => import("@opencode/ui/i18n/dz"),
"@opencode/ui/i18n/el": () => import("@opencode/ui/i18n/el"),
"@opencode/ui/i18n/en": () => import("@opencode/ui/i18n/en"),
"@opencode/ui/i18n/es": () => import("@opencode/ui/i18n/es"),
"@opencode/ui/i18n/et": () => import("@opencode/ui/i18n/et"),
"@opencode/ui/i18n/fa": () => import("@opencode/ui/i18n/fa"),
"@opencode/ui/i18n/fi": () => import("@opencode/ui/i18n/fi"),
"@opencode/ui/i18n/fo": () => import("@opencode/ui/i18n/fo"),
"@opencode/ui/i18n/fr": () => import("@opencode/ui/i18n/fr"),
"@opencode/ui/i18n/he": () => import("@opencode/ui/i18n/he"),
"@opencode/ui/i18n/hi": () => import("@opencode/ui/i18n/hi"),
"@opencode/ui/i18n/hr": () => import("@opencode/ui/i18n/hr"),
"@opencode/ui/i18n/hu": () => import("@opencode/ui/i18n/hu"),
"@opencode/ui/i18n/hy": () => import("@opencode/ui/i18n/hy"),
"@opencode/ui/i18n/id": () => import("@opencode/ui/i18n/id"),
"@opencode/ui/i18n/is": () => import("@opencode/ui/i18n/is"),
"@opencode/ui/i18n/it": () => import("@opencode/ui/i18n/it"),
"@opencode/ui/i18n/ja": () => import("@opencode/ui/i18n/ja"),
"@opencode/ui/i18n/ka": () => import("@opencode/ui/i18n/ka"),
"@opencode/ui/i18n/km": () => import("@opencode/ui/i18n/km"),
"@opencode/ui/i18n/ko": () => import("@opencode/ui/i18n/ko"),
"@opencode/ui/i18n/lo": () => import("@opencode/ui/i18n/lo"),
"@opencode/ui/i18n/lt": () => import("@opencode/ui/i18n/lt"),
"@opencode/ui/i18n/lv": () => import("@opencode/ui/i18n/lv"),
"@opencode/ui/i18n/mk": () => import("@opencode/ui/i18n/mk"),
"@opencode/ui/i18n/mn": () => import("@opencode/ui/i18n/mn"),
"@opencode/ui/i18n/ms": () => import("@opencode/ui/i18n/ms"),
"@opencode/ui/i18n/my": () => import("@opencode/ui/i18n/my"),
"@opencode/ui/i18n/ne": () => import("@opencode/ui/i18n/ne"),
"@opencode/ui/i18n/nl": () => import("@opencode/ui/i18n/nl"),
"@opencode/ui/i18n/no": () => import("@opencode/ui/i18n/no"),
"@opencode/ui/i18n/pa": () => import("@opencode/ui/i18n/pa"),
"@opencode/ui/i18n/pl": () => import("@opencode/ui/i18n/pl"),
"@opencode/ui/i18n/ro": () => import("@opencode/ui/i18n/ro"),
"@opencode/ui/i18n/ru": () => import("@opencode/ui/i18n/ru"),
"@opencode/ui/i18n/si": () => import("@opencode/ui/i18n/si"),
"@opencode/ui/i18n/sk": () => import("@opencode/ui/i18n/sk"),
"@opencode/ui/i18n/sl": () => import("@opencode/ui/i18n/sl"),
"@opencode/ui/i18n/sq": () => import("@opencode/ui/i18n/sq"),
"@opencode/ui/i18n/sr": () => import("@opencode/ui/i18n/sr"),
"@opencode/ui/i18n/sv": () => import("@opencode/ui/i18n/sv"),
"@opencode/ui/i18n/tg": () => import("@opencode/ui/i18n/tg"),
"@opencode/ui/i18n/th": () => import("@opencode/ui/i18n/th"),
"@opencode/ui/i18n/tk": () => import("@opencode/ui/i18n/tk"),
"@opencode/ui/i18n/tr": () => import("@opencode/ui/i18n/tr"),
"@opencode/ui/i18n/uk": () => import("@opencode/ui/i18n/uk"),
"@opencode/ui/i18n/ur": () => import("@opencode/ui/i18n/ur"),
"@opencode/ui/i18n/uz": () => import("@opencode/ui/i18n/uz"),
"@opencode/ui/i18n/vi": () => import("@opencode/ui/i18n/vi"),
"@opencode/ui/i18n/zh": () => import("@opencode/ui/i18n/zh"),
"@opencode/ui/i18n/zht": () => import("@opencode/ui/i18n/zht"),
"@opencode/ui/icon": () => import("@opencode/ui/icon"),
"@opencode/ui/icon-button": () => import("@opencode/ui/icon-button"),
"@opencode/ui/icons/app": () => import("@opencode/ui/icons/app"),
"@opencode/ui/icons/file-type": () => import("@opencode/ui/icons/file-type"),
"@opencode/ui/icons/provider": () => import("@opencode/ui/icons/provider"),
"@opencode/ui/image-preview": () => import("@opencode/ui/image-preview"),
"@opencode/ui/inline-input": () => import("@opencode/ui/inline-input"),
"@opencode/ui/keybind": () => import("@opencode/ui/keybind"),
"@opencode/ui/layout": () => import("@opencode/ui/layout"),
"@opencode/ui/line-comment": () => import("@opencode/ui/line-comment"),
"@opencode/ui/list": () => import("@opencode/ui/list"),
"@opencode/ui/loader": () => import("@opencode/ui/loader"),
"@opencode/ui/logo": () => import("@opencode/ui/logo"),
"@opencode/ui/menu": () => import("@opencode/ui/menu"),
"@opencode/ui/motion-spring": () => import("@opencode/ui/motion-spring"),
"@opencode/ui/popover": () => import("@opencode/ui/popover"),
"@opencode/ui/progress": () => import("@opencode/ui/progress"),
"@opencode/ui/progress-circle": () => import("@opencode/ui/progress-circle"),
"@opencode/ui/project-avatar": () => import("@opencode/ui/project-avatar"),
"@opencode/ui/provider-icon": () => import("@opencode/ui/provider-icon"),
"@opencode/ui/radio": () => import("@opencode/ui/radio"),
"@opencode/ui/resize-handle": () => import("@opencode/ui/resize-handle"),
"@opencode/ui/resize-state": () => import("@opencode/ui/resize-state"),
"@opencode/ui/scroll-view": () => import("@opencode/ui/scroll-view"),
"@opencode/ui/segmented-control": () => import("@opencode/ui/segmented-control"),
"@opencode/ui/select": () => import("@opencode/ui/select"),
"@opencode/ui/spinner": () => import("@opencode/ui/spinner"),
"@opencode/ui/split-button": () => import("@opencode/ui/split-button"),
"@opencode/ui/sticky-accordion-header": () => import("@opencode/ui/sticky-accordion-header"),
"@opencode/ui/switch": () => import("@opencode/ui/switch"),
"@opencode/ui/tab-state-indicator": () => import("@opencode/ui/tab-state-indicator"),
"@opencode/ui/tabs": () => import("@opencode/ui/tabs"),
"@opencode/ui/text-field": () => import("@opencode/ui/text-field"),
"@opencode/ui/text-input": () => import("@opencode/ui/text-input"),
"@opencode/ui/text-reveal": () => import("@opencode/ui/text-reveal"),
"@opencode/ui/text-shimmer": () => import("@opencode/ui/text-shimmer"),
"@opencode/ui/text-strikethrough": () => import("@opencode/ui/text-strikethrough"),
"@opencode/ui/textarea": () => import("@opencode/ui/textarea"),
"@opencode/ui/theme": () => import("@opencode/ui/theme"),
"@opencode/ui/theme/color": () => import("@opencode/ui/theme/color"),
"@opencode/ui/theme/context": () => import("@opencode/ui/theme/context"),
"@opencode/ui/theme/default-themes": () => import("@opencode/ui/theme/default-themes"),
"@opencode/ui/theme/index": () => import("@opencode/ui/theme/index"),
"@opencode/ui/theme/loader": () => import("@opencode/ui/theme/loader"),
"@opencode/ui/theme/resolve": () => import("@opencode/ui/theme/resolve"),
"@opencode/ui/theme/types": () => import("@opencode/ui/theme/types"),
"@opencode/ui/toast": () => import("@opencode/ui/toast"),
"@opencode/ui/tooltip": () => import("@opencode/ui/tooltip"),
"@opencode/ui/typewriter": () => import("@opencode/ui/typewriter"),
"@opencode/ui/wordmark": () => import("@opencode/ui/wordmark"),
"@opencode/util/encode": () => import("@opencode/util/encode"),
"@opencode/util/path": () => import("@opencode/util/path"),
"@tanstack/solid-query": () => import("@tanstack/solid-query"),
"effect": () => import("effect"),
"solid-js": () => import("solid-js"),
"solid-js/store": () => import("solid-js/store"),
"solid-js/web": () => import("solid-js/web"),
}
@@ -1,92 +0,0 @@
import { createEffect, onCleanup } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEventListener } from "@solid-primitives/event-listener"
import { useDialog } from "@opencode/ui/context/dialog"
import { useDesktopExtensions } from "./provider"
/** Native geometry, clipping and portal occlusion are host behavior shared by all extensions. */
export function ExtensionNativeSurface(props: { extensionID: string; id: string }) {
const host = useDesktopExtensions()
const dialog = useDialog()
let surface: HTMLDivElement | undefined
let frame: number | undefined
let until = 0
let last = ""
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
const measure = () => {
if (!surface) return
const rect = surface.getBoundingClientRect()
const covered = Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some(
(element) => {
const other = element.getBoundingClientRect()
return (
other.width > 0 &&
other.left < rect.right &&
other.right > rect.left &&
other.top < rect.bottom &&
other.bottom > rect.top
)
},
)
const zoom = host.zoom()
const visible =
document.visibilityState === "visible" &&
!dialog.active &&
!covered &&
surface.checkVisibility({ checkVisibilityCSS: true })
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const key = `${props.id}:${visible}:${rect.x}:${rect.y}:${rect.width}:${rect.height}:${zoom}:${color}:${devicePixelRatio}`
if (key === last) return
last = key
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
host.transport?.surface(props.extensionID, props.id, {
visible,
bounds: {
x: Math.round(rect.left * zoom),
y: Math.round(rect.top * zoom),
width: Math.max(0, Math.round(rect.right * zoom) - Math.round(rect.left * zoom)),
height: Math.max(0, Math.round(rect.bottom * zoom) - Math.round(rect.top * zoom)),
},
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
const tick = () => {
frame = undefined
measure()
if (performance.now() < until) frame = requestAnimationFrame(tick)
}
const schedule = () => {
until = performance.now() + 300
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => {
props.id
dialog.active
host.zoom()
schedule()
})
createResizeObserver(() => surface, measure)
createEventListener(window, "resize", schedule)
createEventListener(document, "visibilitychange", schedule)
const portals = new MutationObserver(schedule)
portals.observe(document.body, { childList: true })
const theme = new MutationObserver(schedule)
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => {
portals.disconnect()
theme.disconnect()
if (frame !== undefined) cancelAnimationFrame(frame)
host.transport?.surface(props.extensionID, props.id)
})
return <div ref={surface} data-component="native-surface" class="min-h-0 min-w-0 flex-1 bg-v2-background-bg-base" />
}
-548
View File
@@ -1,548 +0,0 @@
import {
createContext,
createEffect,
createMemo,
createRoot,
getOwner,
onCleanup,
runWithOwner,
untrack,
batch,
useContext,
type ParentProps,
type JSX,
} from "solid-js"
import { createStore, produce, reconcile, type Store, type SetStoreFunction } from "solid-js/store"
import { Schema } from "effect"
import type { Context, PanelProps, SessionContext, SlotClaim, Plugin } from "@opencode/plugin/desktop"
import type { Server, StorageOptions } from "@opencode/plugin/desktop/context"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { createLifecycle } from "@opencode/plugin/desktop/lifecycle"
import { client } from "@opencode/plugin/desktop/rpc"
import { resolveSlots, type Claim, type PlacementKind } from "@opencode/plugin/slots"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs, tabKey } from "@/shell/tabs/tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { persisted, Persist, removePersisted } from "@/runtime/persistence/storage"
import { base64Encode } from "@opencode/util/encode"
import { terminalFontFamily, useSettings } from "@/settings/model"
import { extensionTabKey } from "./keys"
import { showToast } from "@/shell/notifications/toast"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
export type Contribution = Claim<{
context: Context
generation: number
when?: () => boolean
render: SlotClaim["render"]
}>
export type RegisteredPanel = {
key: string
plugin: string
generation: number
session: SessionContext
props: PanelProps
render: () => JSX.Element
icon: () => JSX.Element
}
type PanelHost = { open(id: string): void; close(id: string): void; active(): string | undefined; visible?(): boolean }
const HostContext = createContext<ReturnType<typeof createHost>>()
export function DesktopExtensionsProvider(props: ParentProps) {
const host = createHost()
return <HostContext.Provider value={host}>{props.children}</HostContext.Provider>
}
export function useDesktopExtensions() {
const host = useContext(HostContext)
if (!host) throw new Error("Desktop extension host is unavailable")
return host
}
export const useOptionalDesktopExtensions = () => useContext(HostContext)
function createHost() {
const platform = usePlatform()
const global = useGlobal()
const tabs = useTabs()
const route = useCurrentRoute()
const commands = useCommand()
const language = useLanguage()
const settings = useSettings()
const owner = getOwner()
const [state, setState] = createStore({
claims: [] as Contribution[],
panels: [] as RegisteredPanel[],
sessions: [] as SessionContext[],
servers: [] as Server[],
services: {} as Record<string, SessionServices | undefined>,
installed: [] as readonly ExtensionManager.Installed[],
// Definitions are opaque: storing a factory prevents Solid from merging a
// replacement into the previous definition and hiding its identity change.
loaded: {} as Record<string, (() => Plugin.Definition) | undefined>,
failures: {} as Record<string, boolean | undefined>,
managerReady: false,
managerError: undefined as ExtensionManager.ErrorCode | undefined,
})
const sessions = new Map<string, SessionContext>()
const hosts = new Map<string, PanelHost>()
const instances = new Map<string, { definition: object; dispose: () => void }>()
const storage = new Map<string, unknown>()
const memories = new Map<string, unknown>()
const persistent = new Map<string, { value: unknown; users: number; reset(): void; dispose(): void }>()
const workspaceRemoved = new Set<(value: { serverID: string; directory: string }) => void>()
const attempted = new WeakSet<Plugin.Definition>()
let instanceID = 0
const builtins = () => platform.extensionPlugins ?? []
createEffect(() => {
const manager = platform.extensionManager
if (!manager) return
let changed = false
let disposed = false
onCleanup(
manager.onChange((entries) => {
changed = true
setState("installed", entries)
setState("managerReady", true)
}),
)
onCleanup(() => {
disposed = true
})
void manager
.list()
.then((entries) => {
if (disposed || changed) return
setState("installed", entries)
setState("managerReady", true)
})
.catch(() => {
if (!disposed && !changed) {
setState("managerError", "storage")
setState("managerReady", true)
}
})
})
const pending = new Map<string, string>()
createEffect(() => {
const manager = platform.extensionManager
if (!manager) return
state.installed.forEach((entry) => {
if (!entry.enabled) {
pending.delete(entry.id)
setState("loaded", entry.id, undefined)
setState("failures", entry.id, undefined)
return
}
const token = `${entry.revision}/${entry.generation}`
if (pending.get(entry.id) === token) return
pending.set(entry.id, token)
setState("failures", entry.id, undefined)
void (async () => {
const { loadExtension } = await import("./load")
const value = await loadExtension(await manager.source(entry.id, entry.revision))
if (pending.get(entry.id) !== token) return
setState("loaded", entry.id, () => () => value)
})().catch((error) => {
if (pending.get(entry.id) === token) {
console.debug("[desktop-extensions] load failed", { id: entry.id, error })
setState("failures", entry.id, true)
}
})
})
})
onCleanup(() => pending.clear())
const resolved = createMemo(() =>
resolveSlots({
paths: new Set([
"app",
"titlebar.actions",
"settings.experimental",
"session.panel",
"session.panel.actions",
"session.composer.top",
"session.header.actions",
"session.panel.toolbar",
"session.panel.tools",
"session.sidebar",
"session.auxiliary",
"session.mobile.actions",
]),
claims: state.claims.filter((claim) => claim.render.when?.() ?? true),
}),
)
createEffect(() => {
const all = global.servers.list()
const available = all.map((connection): Server => {
const id = ServerConnection.key(connection)
const data = global.ensureServerCtx(connection)
return {
id,
local: ServerConnection.local(connection),
get url() {
return data.sdk.url
},
get client() {
return data.sdk.api
},
data: data.data,
get compatible() {
return !global.servers.health[id]?.incompatible
},
}
})
setState("servers", available)
platform.extensions?.configure(
all.map((connection) => ({
id: ServerConnection.key(connection),
...connection.http,
url: global.ensureServerCtx(connection).sdk.url,
})),
)
const owned = new Set(tabs.store.filter((tab) => tab.type === "session").map(tabKey))
Array.from(sessions).forEach(([key, session]) => {
if (!owned.has(session.ownerID)) sessions.delete(key)
})
tabs.store.forEach((tab) => {
if (tab.type !== "session") return
const connection = all.find((connection) => ServerConnection.key(connection) === tab.server)
if (!connection) return
const data = global.ensureServerCtx(connection)
const server = available.find((server) => server.id === tab.server)!
Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => {
const key = `${tab.server}\n${id}`
if (sessions.has(key)) return
sessions.set(key, {
key,
ownerID: tabKey(tab),
sessionID: id,
server,
get creating() {
return data.data.session.creating(id)
},
get location() {
return data.data.session.get(id)?.location
},
get services() {
return state.services[key]
},
})
})
})
setState("sessions", Array.from(sessions.values()))
})
const current = createMemo(() => {
const value = route()
if (value.type !== "session") return
return state.sessions.find((session) => session.server.id === value.server && session.sessionID === value.sessionId)
})
const stateFor = <Value extends object>(id: string, key: string, initial: Value, durable: boolean) => {
const cache = durable ? storage : memories
const name = `${id}.${key}`
const previous = cache.get(name)
if (previous) return previous as readonly [Store<Value>, (update: (draft: Value) => void) => void]
// Storage decodes JSON objects at the boundary; each plugin owns its value's shape and migrations.
const pair = runWithOwner(owner, () =>
durable
? persisted(
Persist.global(`extension.${name}`),
Schema.Record(Schema.String, Schema.Json),
Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(initial),
)
: createStore(initial),
) as unknown as readonly [Store<Value>, SetStoreFunction<Value>]
const value = [pair[0], (update: (draft: Value) => void) => pair[1](produce(update))] as const
cache.set(name, value)
return value
}
const persistTarget = (id: string, key: string, options?: StorageOptions) => {
if (!options?.scope)
return {
...Persist.global(`extension.${id}.${key}`),
previousKeys: options?.legacyKey ? [options.legacyKey] : undefined,
}
const connection = global.servers.list().find((server) => ServerConnection.key(server) === options.scope!.serverID)
if (!connection) throw new Error("Extension storage server is unavailable")
return {
...Persist.serverWorkspace(
global.ensureServerCtx(connection).sdk.scope,
base64Encode(options.scope.directory),
`extension.${id}.${key}`,
),
previousKeys: options.legacyKey ? [`workspace:${options.legacyKey}`] : undefined,
}
}
const persistFor = <S extends Schema.ConstraintCodec<object, unknown>>(
id: string,
key: string,
schema: S,
initial: NoInfer<S["Type"]>,
options?: StorageOptions,
) => {
const target = persistTarget(id, key, options)
const name = JSON.stringify([target.storage, target.key])
const previous = persistent.get(name)
type Value = readonly [Store<S["Type"]>, SetStoreFunction<S["Type"]>, () => boolean]
const entry =
previous ??
createRoot((dispose) => {
const pair = persisted(target, schema, initial)
return {
value: [pair[0], pair[1], pair[3]] as const,
users: 0,
reset: () => pair[1](reconcile(initial)),
dispose,
}
}, owner)
persistent.set(name, entry)
entry.users++
// Overlapping activation generations share state. Evicted workspace owners
// release their hydrated buffers; the persistence layer flushes before disposal.
onCleanup(() => {
if (--entry.users) return
entry.dispose()
persistent.delete(name)
})
return entry.value as Value
}
const activate = (definition: NonNullable<typeof platform.extensionPlugins>[number]) =>
createRoot((dispose) => {
const lifecycle = createLifecycle()
const id = definition.id
const generation = ++instanceID
let activated = false
let nextClaim = 0
const context: Context = {
assets: {
url(path) {
const entry = state.installed.find((entry) => entry.id === id)
if (!entry || !platform.extensionManager) throw new Error("Extension assets are unavailable")
return platform.extensionManager.assetURL(id, entry.revision, path)
},
},
app: { version: platform.version, windowID: platform.windowID, native: !!platform.extensions },
lifecycle,
platform: {
...platform,
async saveFile(options, content) {
if (platform.saveFile) return platform.saveFile(options, content)
const url = URL.createObjectURL(new Blob([content], { type: "application/octet-stream" }))
const link = document.createElement("a")
link.href = url
link.download = options.defaultPath ?? "download"
link.click()
URL.revokeObjectURL(url)
return true
},
},
sessions: { list: () => state.sessions, current },
servers: { list: () => state.servers },
workspaces: {
onRemoved(handler) {
workspaceRemoved.add(handler)
return lifecycle.own(() => workspaceRemoved.delete(handler))
},
},
fonts: { console: () => terminalFontFamily(settings.appearance.terminalFont()) },
storage: {
persist: (key, schema, initial, options) => persistFor(id, key, schema, initial, options),
remove(key, options) {
const target = persistTarget(id, key, options)
const name = JSON.stringify([target.storage, target.key])
persistent.get(name)?.reset()
removePersisted(target, platform)
target.previousKeys?.forEach((key) => removePersisted({ ...target, key }, platform))
},
store: (key, options) => stateFor(id, key, options.initial, true),
memory: (key, options) => stateFor(id, key, options.initial, false),
},
i18n: {
locale: language.locale,
intl: language.intl,
plural: (key, count, params) => language.plural(key as Parameters<typeof language.plural>[0], count, params),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params) ?? key,
},
commands: {
register(values) {
return lifecycle.own(
createRoot((dispose) => {
commands.register(`${id}/${generation}/${nextClaim++}`, () =>
values().map((command) => ({
id: command.reference ?? `${id}.${command.id}`,
title: command.title,
description: command.description,
category: command.group,
disabled: command.enabled === false,
hidden: command.palette === false,
keybind: command.bind,
slash: command.slash,
when: command.when,
onSelect: () => {
void command.run()
},
})),
)
return dispose
}),
)
},
dispatch: (commandID) => commands.trigger(`${id}.${commandID}`),
keys: commands.keybindParts,
matches: commands.matches,
},
main: {
rpc(definition) {
const transport = platform.extensions
if (!transport) throw new Error("Native desktop extensions are unavailable on this platform")
return client(id, definition, transport, lifecycle.signal, lifecycle.own)
},
},
ui: {
toast: {
show: (options) =>
showToast({ title: options.title, description: options.message, variant: options.variant }),
},
slot(claim) {
const placements = ["append", "prepend", "before", "after", "replace"] as const
const kinds = placements.filter((kind) => claim[kind] !== undefined)
if (kinds.length !== 1) throw new Error("A slot requires exactly one placement")
const kind: PlacementKind = kinds[0]
const value: Contribution = {
key: `${id}/${generation}/${nextClaim++}`,
plugin: id,
placement: { kind, target: claim[kind]! },
render: { context, generation, when: claim.when, render: claim.render },
}
setState("claims", (items) => [...items, value])
return lifecycle.own(() => setState("claims", (items) => items.filter((item) => item.key !== value.key)))
},
panel: {
open(localID, session) {
const panel = state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)
const key = panel?.key ?? extensionTabKey(id, localID)
const host = hosts.get(session.key)
if (!host || !state.panels.some((panel) => panel.session.key === session.key && panel.key === key))
return false
host.open(key)
return true
},
close(localID, session) {
const key =
state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)?.key ?? extensionTabKey(id, localID)
const host = hosts.get(session.key)
if (!host) return false
host.close(key)
return true
},
selected: (localID, session) => {
const panel = state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)
return !!panel && hosts.get(session.key)?.active() === panel.key
},
visible: (localID, session) => {
const panel = state.panels.find(
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
)
const host = hosts.get(session.key)
return !!panel && host?.active() === panel.key && (host.visible?.() ?? true)
},
},
},
}
onCleanup(() => {
try {
lifecycle.dispose()
} finally {
if (activated) platform.extensions?.release(id)
}
})
try {
const cleanup = definition.setup(context)
if (cleanup) lifecycle.own(cleanup)
activated = true
return dispose
} catch (error) {
dispose()
throw error
}
})
createEffect(() => {
const definitions = [
...builtins(),
...Object.values(state.loaded)
.flatMap((load) => (load ? [load()] : []))
.filter((value) => !builtins().some((builtin) => builtin.id === value.id)),
]
Array.from(instances).forEach(([id, instance]) => {
if (definitions.some((definition) => definition.id === id)) return
instance.dispose()
instances.delete(id)
})
definitions.forEach((definition) => {
if (instances.get(definition.id)?.definition === definition || attempted.has(definition)) return
try {
batch(() => {
const dispose = untrack(() => activate(definition))
instances.get(definition.id)?.dispose()
instances.set(definition.id, { definition, dispose })
})
} catch (error) {
if (builtins().includes(definition)) throw error
console.debug("[desktop-extensions] activation failed", {
id: definition.id,
error: error instanceof Error ? error.stack : String(error),
})
setState("failures", definition.id, true)
attempted.add(definition)
}
})
})
onCleanup(() => instances.forEach((instance) => instance.dispose()))
return {
state,
resolved,
current,
transport: platform.extensions,
manager: platform.extensionManager,
builtins: (): readonly Plugin.Definition[] => builtins(),
failed: (id: string) => setState("failures", id, true),
workspaceRemoved(value: { serverID: string; directory: string }) {
workspaceRemoved.forEach((handler) => handler(value))
},
zoom: () => platform.webviewZoom?.() ?? 1,
bind(session: SessionContext, host: PanelHost, services?: SessionServices) {
hosts.set(session.key, host)
setState("services", session.key, services)
return () => {
if (hosts.get(session.key) === host) {
hosts.delete(session.key)
setState("services", session.key, undefined)
}
}
},
register(panel: RegisteredPanel) {
if (state.panels.some((item) => item.key === panel.key && item.session.key === panel.session.key))
throw new Error(`Duplicate extension panel: ${panel.key}`)
setState("panels", (items) => [...items, panel])
return () =>
setState("panels", (items) =>
items.filter((item) => item.key !== panel.key || item.session.key !== panel.session.key),
)
},
}
}
-176
View File
@@ -1,176 +0,0 @@
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { useOptionalDesktopExtensions } from "./provider"
import { ExtensionSlot } from "./slot"
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
import type { AuxiliaryPresentation } from "@opencode/plugin/desktop/context"
export function useExtensionPanels(input: {
serverID: () => string
sessionID: () => string | undefined
tabs: () => {
all(): string[]
setAll(value: string[]): void
active(): string | undefined
setActive(value: string): void
close(id: string): void
}
open(): void
services?: SessionServices
active?: () => string | undefined
}) {
const host = useOptionalDesktopExtensions()
const session = createMemo(() =>
host?.state.sessions.find(
(session) => session.sessionID === input.sessionID() && session.server.id === input.serverID(),
),
)
const panels = createMemo(() => host?.state.panels.filter((panel) => panel.session.key === session()?.key) ?? [])
createEffect(() => {
const current = session()
if (!current || !host) return
onCleanup(
host.bind(
current,
{
open(id) {
input.open()
const tabs = input.tabs()
if (!tabs.all().includes(id)) tabs.setAll([...tabs.all(), id])
tabs.setActive(id)
},
close: (id) => input.tabs().close(id),
active: () => input.active?.() ?? input.tabs().active(),
visible: () => input.services?.view.panel.opened() ?? true,
},
input.services,
),
)
})
createEffect(
on(
() => panels().map((panel) => panel.key),
(keys, previous) => {
const old = new Set(previous ?? [])
const tabs = input.tabs()
// Persisted instances can be waiting for their declarations to mount. Only
// remove contributions observed disappearing during this route lifetime.
const removed = new Set(previous?.filter((key) => !keys.includes(key)))
const current = tabs.all().filter((key) => !removed.has(key))
const added = keys.filter(
(key) =>
!old.has(key) &&
!current.includes(key) &&
panels().find((panel) => panel.key === key)?.props.initial !== "closed",
)
if (added.length || current.length !== tabs.all().length) tabs.setAll([...current, ...added])
},
),
)
createEffect(
on(
() => input.tabs().all(),
(current, previous) => {
previous
?.filter((key) => !current.includes(key))
.forEach((key) =>
panels()
.find((panel) => panel.key === key)
?.props.onClose?.(),
)
},
),
)
createEffect(
on(
() => input.tabs().active(),
(active) =>
panels()
.find((panel) => panel.key === active)
?.props.onSelect?.(),
),
)
return {
hasAuxiliary: () => {
const value = host?.resolved().slotted.get("session.auxiliary")
return (
!!value &&
(!!value.replace || value.before.length + value.prepend.length + value.append.length + value.after.length > 0)
)
},
auxiliary: (presentation: AuxiliaryPresentation) => (
// A secondary surface can span multiple sessions in the same workspace.
// Keep its owner and pass reactive identity instead of keying on the route.
<Show when={input.services && session()}>
{(current) => (
<ExtensionSlot
path="session.auxiliary"
input={{
get session() {
return current()
},
services: input.services!,
presentation,
}}
/>
)}
</Show>
),
mobileActions: () => (
<Show when={session()}>
{(current) => (
<ExtensionSlot
path="session.mobile.actions"
input={{
get session() {
return current()
},
}}
/>
)}
</Show>
),
panels,
keys: () => panels().map((panel) => panel.key),
canClose: (key: string) => panels().find((panel) => panel.key === key)?.props.closable !== false,
defaultPanel: () => panels().find((panel) => panel.props.default)?.key,
hasActions: () => {
const value = host?.resolved().slotted.get("session.panel.actions")
return (
!!value &&
(!!value.replace || value.before.length + value.prepend.length + value.append.length + value.after.length > 0)
)
},
declarations: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel" input={{ session }} />}
</Show>
),
actions: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel.actions" input={{ session }} />}
</Show>
),
toolbar: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel.toolbar" input={{ session }} />}
</Show>
),
tools: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.panel.tools" input={{ session }} />}
</Show>
),
sidebar: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.sidebar" input={{ session }} />}
</Show>
),
header: () => (
<Show when={session()} keyed>
{(session) => <ExtensionSlot path="session.header.actions" input={{ session }} />}
</Show>
),
}
}
export type SessionExtensions = ReturnType<typeof useExtensionPanels>
-103
View File
@@ -1,103 +0,0 @@
import {
createComponent,
createMemo,
createRoot,
ErrorBoundary,
For,
getOwner,
onCleanup,
onMount,
Show,
type JSX,
type ParentProps,
} from "solid-js"
import { PanelProvider, PluginProvider, NativeSurfaceProvider } from "@opencode/plugin/desktop/solid"
import type { PanelInput, SlotMap, SlotPath } from "@opencode/plugin/desktop/context"
import { emptySlotted } from "@opencode/plugin/slots"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { useOptionalDesktopExtensions, type Contribution } from "./provider"
import { extensionTabKey } from "./keys"
import { ExtensionNativeSurface } from "./native-surface"
export function ExtensionSlot<Path extends SlotPath>(props: ParentProps<{ path: Path; input?: SlotMap[Path] }>) {
const host = useOptionalDesktopExtensions()
if (!host) return props.children
const language = useLanguage()
const slotted = createMemo(() => host.resolved().slotted.get(props.path) ?? emptySlotted<Contribution["render"]>())
const contribution = (claim: Contribution) => (
<ErrorBoundary
fallback={(error) => {
host.failed(claim.plugin)
onMount(() =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: `${claim.plugin}: ${String(error)}`,
}),
)
return null
}}
>
<PluginProvider value={claim.render.context}>
<NativeSurfaceProvider
render={(surface) => <ExtensionNativeSurface extensionID={claim.plugin} id={surface.id} />}
>
<Show
when={props.path === "session.panel"}
fallback={createComponent(claim.render.render as (input: object) => JSX.Element, props.input ?? {})}
>
<PanelProvider
value={{
get session() {
return (props.input as PanelInput).session
},
register(panel) {
const owner = getOwner()
const session = (props.input as PanelInput).session
const render = (value: () => JSX.Element) => {
const mounted = createRoot((dispose) => ({ dispose, view: value() }), owner)
onCleanup(mounted.dispose)
return mounted.view
}
onCleanup(
host.register({
key: panel.reference ?? extensionTabKey(claim.plugin, panel.id),
plugin: claim.plugin,
generation: claim.render.generation,
session,
props: panel,
render: () => render(() => panel.children),
icon: () => render(() => panel.icon),
}),
)
},
}}
>
{createComponent(claim.render.render as (input: object) => JSX.Element, props.input ?? {})}
</PanelProvider>
</Show>
</NativeSurfaceProvider>
</PluginProvider>
</ErrorBoundary>
)
return (
<>
<For each={slotted().before}>{contribution}</For>
<Show
when={slotted().replace}
keyed
fallback={
<>
<For each={slotted().prepend}>{contribution}</For>
{props.children}
<For each={slotted().append}>{contribution}</For>
</>
}
>
{contribution}
</Show>
<For each={slotted().after}>{contribution}</For>
</>
)
}
-75
View File
@@ -1,75 +0,0 @@
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { useComments } from "@/composer/comments"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLayout } from "@/shell/state/layout"
import { useSettings } from "@/settings/model"
import { useOptionalDesktopExtensions } from "./provider"
import { useServer } from "@/runtime/server/current"
export function createSessionServices(session: SessionModel): SessionServices {
const file = useFile()
const annotations = useComments()
const draft = useComposerState()
const location = useWorkspaceLocation()
const layout = useLayout()
const settings = useSettings()
const extensions = useOptionalDesktopExtensions()
const server = useServer()
return {
display: { wrapDiff: settings.general.mobileDiffWrap },
files: {
...file,
get directory() {
return location().directory
},
},
annotations,
draft: { context: draft.context },
view: {
auxiliary: {
opened: () => session.layout.view().terminal.opened(),
open: () => session.layout.view().terminal.open(),
close: () => session.layout.view().terminal.close(),
toggle: () => session.layout.view().terminal.toggle(),
height: () => session.layout.view().terminal.height(),
resize: (height) => session.layout.view().terminal.resize(height),
placement: settings.general.terminalPlacement,
},
ready: layout.ready,
desktop: session.isDesktop,
tabs: {
all: () => session.layout.tabs().all(),
active: session.tabs.activeTab,
open: (reference) => session.layout.tabs().open(reference),
close: (reference) => session.layout.tabs().close(reference),
canClose: (reference) =>
extensions?.state.panels.find(
(panel) =>
panel.key === reference &&
panel.session.sessionID === session.identity.sessionID() &&
panel.session.server.id === server.key,
)?.props.closable !== false,
setActive: (reference) => session.layout.tabs().setActive(reference),
preview: () => session.layout.tabs().preview(),
previewTab: (reference) => session.layout.tabs().previewTab(reference),
},
panel: {
opened: () => session.layout.view().reviewPanel.opened(),
open: (source) => session.layout.view().reviewPanel.open(source),
close: () => session.layout.view().reviewPanel.close(),
toggle: () => session.layout.view().reviewPanel.toggle(),
source: () => session.layout.view().reviewPanel.source(),
},
sidebar: { ...layout.fileTree, allowed: settings.visibility.fileTree },
scroll: (key) => session.layout.view().scroll(key),
setScroll: (key, value) => session.layout.view().setScroll(key, value),
},
get project() {
const project = session.project()
return project && { id: project.id, directory: project.worktree, name: project.name, vcs: project.vcs }
},
}
}
+26
View File
@@ -70,6 +70,14 @@
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="terminal-panel-presence"],
[data-slot="side-terminal-panel-presence"],
@@ -139,6 +147,24 @@
}
}
@keyframes terminal-panel-size-in {
from {
height: 0;
}
to {
height: var(--terminal-panel-height);
}
}
@keyframes terminal-panel-size-out {
from {
height: var(--terminal-panel-height);
}
to {
height: 0;
}
}
::highlight(timeline-search-hit) {
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
}
@@ -110,6 +110,7 @@ export function createNewSessionComposerAdapter(props: {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
choices: model.remembered(),
})
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
@@ -0,0 +1,28 @@
import { createEffect, createMemo, on } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
export function useConfiguredModel() {
const data = useData()
const location = useWorkspaceLocation()
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
if (!model) return
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
const [providerID, ...parts] = model.split("/")
return { providerID, modelID: parts.join("/"), variant: undefined }
})
return Object.assign(model, { ready: () => documents() !== undefined })
}
+2 -2
View File
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
setStore("variant", { [key]: value ?? "default" })
return
}
setStore("variant", key, value)
setStore("variant", key, value ?? "default")
}
return {
@@ -1,7 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
+144 -83
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
@@ -16,6 +16,7 @@ import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
@@ -24,11 +25,15 @@ const ModelKeySchema = Schema.Struct({
})
export type ModelKey = typeof ModelKeySchema.Type
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -78,6 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
@@ -98,16 +104,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
@@ -176,13 +175,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
},
set(name: string | undefined) {
const item = pickAgent(name)
@@ -192,25 +195,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
setStore("current", item.name)
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
})
},
move(direction: 1 | -1) {
@@ -230,8 +232,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
@@ -243,26 +252,41 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const item = agent.current()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
}
const selected = () => scope()?.variant
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const snapshot = () => {
const model = current()
const selected = current()
return {
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
variant: selected ? (model.variant.current() ?? null) : undefined,
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...scope(),
agent: agent.current()?.name,
...next,
} satisfies State
@@ -274,22 +298,59 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
a.model?.modelID === b.model?.modelID &&
(a.variant ?? "default") === (b.variant ?? "default")
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
})
)
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
serverSDK.event.on("session.deleted", (event) => {
pending.delete(event.data.sessionID)
setSaved("session", event.data.sessionID, undefined)
}),
)
const model = {
ready: models.ready,
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent,
list: models.list,
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
},
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
@@ -298,21 +359,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
batch(() => {
if (item && !validModel(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
},
visible(item: ModelKey) {
return models.visible(item)
@@ -324,16 +382,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -341,21 +396,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const items = this.list()
@@ -363,8 +409,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(
cycleModelVariant({
variants: items,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
@@ -383,20 +429,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
agent: created.agent,
model: { providerID: selection.providerID, modelID: selection.id },
variant: selection.variant,
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, next)
handoff.set(key, committed)
if (dir === sdk().directory) {
setSaved("session", session, next)
setSaved("session", session, committed)
}
setStore("promoting", next)
setStore("promoting", committed)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
@@ -412,4 +472,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
+2 -2
View File
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
@@ -64,14 +64,14 @@ describe("model variant", () => {
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
test("cycles from configured last variant to default", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
expect(value).toBeUndefined()
})
test("cycles from an explicit default to the first variant", () => {
@@ -83,4 +83,22 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("prefers a saved variant to configuration, including explicit Default", () => {
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
})
test("normalizes unavailable selections instead of silently applying another variant", () => {
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
expect(
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
).toBeUndefined()
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
})
})
+5 -17
View File
@@ -16,6 +16,7 @@ type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
preferred?: string
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
@@ -24,29 +25,16 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
const value = input.selected ?? input.preferred ?? input.configured
return value && value !== "default" && input.variants.includes(value) ? value : undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
const current = resolveModelVariant(input)
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
}
-31
View File
@@ -2,37 +2,6 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
export const dict = {
...DESKTOP_NATIVE_ENGLISH,
"settings.tab.tools": "Tools",
"settings.tools.description": "Manage tools available on this server",
"project.settings.tools.description": "View tools available to this project",
"settings.desktopExtensions.installTitle": "Install extensions",
"settings.desktopExtensions.drop": "Drop extension files",
"settings.desktopExtensions.choose": "Choose one or more .ocdx archives from your computer",
"settings.desktopExtensions.browse": "Browse",
"settings.desktopExtensions.files": "Extension files",
"settings.desktopExtensions.fromURL": "Or install from a URL",
"settings.desktopExtensions.placeholder": "https://example.com/my-extension.ocdx",
"settings.desktopExtensions.url": "Extension URL",
"settings.desktopExtensions.install": "Install",
"settings.desktopExtensions.installed": "Installed extensions",
"settings.desktopExtensions.empty": "No extensions installed.",
"settings.desktopExtensions.builtin": "Built-in",
"settings.desktopExtensions.main": "Main process",
"settings.desktopExtensions.enable": "Enable {{name}}",
"settings.desktopExtensions.reload": "Reload {{name}}",
"settings.desktopExtensions.error.files": "Choose one or more .ocdx files.",
"settings.desktopExtensions.error.archive": "The file is not a valid .ocdx archive",
"settings.desktopExtensions.error.manifest": "This archive must target @opencode/plugin/desktop.",
"settings.desktopExtensions.error.path": "The archive contains an invalid file path.",
"settings.desktopExtensions.error.size": "Extension is larger than 1 GiB",
"settings.desktopExtensions.error.reserved": "Extension ID is reserved by a built-in extension",
"settings.desktopExtensions.error.notFound": "Extension not found",
"settings.desktopExtensions.error.disabled": "This extension is disabled.",
"settings.desktopExtensions.error.module": "The extension entrypoint is invalid or uses an unsupported import.",
"settings.desktopExtensions.error.download": "Unable to download the extension.",
"settings.desktopExtensions.error.url": "Only HTTP and HTTPS URLs are supported",
"settings.desktopExtensions.error.storage": "Unable to update installed extensions.",
"settings.desktopExtensions.error.activation": "Unable to activate {{name}}.",
"session.location.unavailable": "Session location unavailable",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
@@ -230,3 +230,72 @@ describe("draft store text externalization", () => {
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
describe("draft store image retention", () => {
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
const fresh = (grace = 0) => {
const memory = memoryDriver()
return { memory, store: createDraftStore(memory.driver, { grace }) }
}
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
// An image with no object URL left gets a new one when its bytes are uploaded again.
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
(await store.putBlob(image(byte))).url !== url
test("an uploaded image no document references is released after the grace", async () => {
const { store } = fresh()
const orphan = await store.putBlob(image(1))
await tick()
expect(await released(store, 1, orphan.url)).toBe(true)
})
test("an image referenced within the grace is kept", async () => {
const { store } = fresh(50)
const pasted = await store.putBlob(image(2))
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
await new Promise((resolve) => setTimeout(resolve, 80))
expect(await released(store, 2, pasted.url)).toBe(false)
})
test("saving a document without an image or removing the document releases it", async () => {
const { store } = fresh()
const dropped = await store.putBlob(image(3))
const removed = await store.putBlob(image(4))
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
await tick()
expect(await released(store, 3, dropped.url)).toBe(false)
expect(await released(store, 4, removed.url)).toBe(false)
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
await store.removeItem("closed")
await tick()
expect(await released(store, 3, dropped.url)).toBe(true)
expect(await released(store, 4, removed.url)).toBe(true)
})
test("an image referenced by two documents survives until both drop it", async () => {
const { store } = fresh()
const shared = await store.putBlob(image(5))
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
await store.setDocument("composer", { prompt: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(false)
await store.setDocument("history", { entries: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
})
+84 -21
View File
@@ -28,23 +28,77 @@ export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
// between a paste and the save that references it, and the submit → history handoff.
export const retainedBlobGrace = 30_000
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
const retained = new Map<string, Retained>()
// Document keys that reference each image id; an id with no keys is released after the grace.
const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
if (existing) return existing.url
const url = URL.createObjectURL(blob)
urls.set(id, url)
held.set(id, blob)
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
retained.set(id, { blob, url, release })
return url
}
// Record which image ids `key` now references; ids it dropped are released once no other document
// references them, ids it gained stay pinned.
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
for (const [id, keys] of refs) {
if (ids.has(id) || !keys.delete(key) || keys.size) continue
refs.delete(id)
const entry = retained.get(id)
if (entry) entry.release = setTimeout(() => revoke(id), grace)
}
for (const id of ids) {
const keys = refs.get(id) ?? new Set<string>()
keys.add(key)
refs.set(id, keys)
const entry = retained.get(id)
if (!entry) continue
clearTimeout(entry.release)
entry.release = undefined
}
}
function revoke(id: string) {
const entry = retained.get(id)
if (!entry) return
URL.revokeObjectURL(entry.url)
retained.delete(id)
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
}
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => imageIDs(entry, into))
return into
}
if (!value || typeof value !== "object") return into
const item = value as Record<string, unknown>
const blob = item.blob
if (blob && typeof blob === "object" && !("kind" in blob)) {
const id = (blob as Record<string, unknown>).id
if (typeof id === "string") into.add(id)
return into
}
Object.values(item).forEach((entry) => imageIDs(entry, into))
return into
}
async function blobID(blob: Blob) {
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
@@ -60,24 +114,25 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver): DraftStore {
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
const grace = options.grace ?? retainedBlobGrace
const versions = new Map<string, number>()
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = urls.get(id)
if (existing) return existing
const existing = retained.get(id)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
.getBlob(id)
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
.finally(() => loading.delete(id))
loading.set(id, next)
return next
}
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
return { id, url: blobUrl(id, blob, grace) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
@@ -145,8 +200,8 @@ export function createDraftStore(driver: Driver): DraftStore {
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
const kept = retained.get(id)?.blob
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
@@ -192,8 +247,7 @@ export function createDraftStore(driver: Driver): DraftStore {
remember(chunks, next, source.chunk)
return
}
held.set(next, blob)
blobUrl(next, blob)
blobUrl(next, blob, grace)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
@@ -225,14 +279,19 @@ export function createDraftStore(driver: Driver): DraftStore {
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) return
if (missing.length === 0) {
retain(key, imageIDs(encoded), grace)
return
}
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
const final = rename(encoded, renamed)
await driver.set(key, JSON.stringify(final), false)
retain(key, imageIDs(final), grace)
}
return {
getItem: async (key) => {
@@ -241,6 +300,8 @@ export function createDraftStore(driver: Driver): DraftStore {
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
@@ -248,6 +309,7 @@ export function createDraftStore(driver: Driver): DraftStore {
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
retain(key, new Set(), grace)
},
putBlob,
}
@@ -360,7 +422,8 @@ function referenced(json: string) {
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await fetch(blob.url).then((response) => response.blob())
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
+99 -11
View File
@@ -1,11 +1,99 @@
export {
Persistence,
type Migrated,
migrate,
withInitial,
fallback,
optional,
struct,
record,
array,
} from "@opencode/plugin/desktop/persistence"
export * as Persistence from "./schema"
import { Effect, Option, Predicate, Result, Schema, SchemaAST, SchemaGetter, SchemaParser, Struct } from "effect"
export type Migrated<S extends Schema.ConstraintCodec<object, unknown>> = {
current: S
read: Schema.ConstraintDecoder<unknown>
}
export function migrate<S extends Schema.ConstraintCodec<object, unknown>>(
current: S,
read: Schema.ConstraintDecoder<unknown>,
): Migrated<S> {
return { current, read }
}
function isMigrated<S extends Schema.ConstraintCodec<object, unknown>>(schema: S | Migrated<S>): schema is Migrated<S> {
return "current" in schema
}
export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
definition: S | Migrated<S>,
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
: Result.succeed<unknown>
const encode = Schema.encodeUnknownSync(schema)
return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
decode: SchemaGetter.transformOrFail((value) =>
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
),
encode: SchemaGetter.transform((value) => encode(value)),
}),
Schema.decodeTo(Schema.toType(schema)),
)
}
// Object-level codecs own their recovery. Plain structs can recover fields independently.
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
return Object.fromEntries(
ast.propertySignatures.flatMap((field) => {
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
const next = recover(field.type, value[field.name], defaults)
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
return [[field.name, next]]
}),
)
}
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
return Option.isSome(decoded) ? decoded.value : initial
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
// Unlike a decoding default, a fallback also replaces invalid persisted values.
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
}
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const field = Schema.optional(schema)
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
}
export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields) {
return Schema.Struct(fields).mapFields(Struct.map(Schema.mutableKey))
}
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
}
// Recover individual entries rather than discarding a whole history or collection.
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const decode = Schema.decodeUnknownOption(schema)
const encode = Schema.encodeSync(schema)
return fallback(
Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
decode: SchemaGetter.transform((items) => items.flatMap((item) => Option.toArray(decode(item)))),
encode: SchemaGetter.transform((items) => items.map((item) => encode(item))),
}),
),
() => [],
)
}
@@ -24,7 +24,6 @@ type PersistTarget = {
scope?: "window"
workspaceStorageAliases?: string[]
previousKey?: string
previousKeys?: string[]
key: string
}
@@ -517,11 +516,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
if (!isDesktop && !draft) {
const current = currentStorage as SyncStorage
const sources = [
...(config.previousKeys ?? []).map((key) => ({ storage: current, key })),
...workspaceAliases.map((storage) => ({ storage: localStorageWithPrefix(storage) })),
...workspaceAliases.flatMap((storage) =>
(config.previousKeys ?? []).map((key) => ({ storage: localStorageWithPrefix(storage), key })),
),
...(config.previousKey ? [{ storage: localStorageDirect(), key: config.previousKey }] : []),
]
@@ -557,17 +552,10 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
: undefined
const previousStorage = config.previousKey ? (isDesktop ? platform.storage?.() : localStorageDirect()) : undefined
const relocationSources = [
...(config.previousKeys ?? []).map((key) => ({ storage: current, key })),
previousDraftStorage ? { storage: previousDraftStorage } : undefined,
...workspaceAliases.map((name) => ({
storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name),
})),
...workspaceAliases.flatMap((name) =>
(config.previousKeys ?? []).map((key) => ({
storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name),
key,
})),
),
previousStorage && config.previousKey ? { storage: previousStorage, key: config.previousKey } : undefined,
]
.filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage)
@@ -0,0 +1,27 @@
import type { Browser } from "@opencode/plugin-browser/rpc"
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneLayout = {
tabID: Browser.TabID
visible: boolean
bounds?: { x: number; y: number; width: number; height: number }
background?: readonly [number, number, number, number]
radius?: number
}
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
close(): void
}
export type BrowserPanePlatform = {
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
}
@@ -6,9 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
import type { Plugin } from "@opencode/plugin/desktop"
import type { ExtensionManager } from "@opencode/plugin/desktop/manager"
import type { BrowserPanePlatform } from "./browser-pane"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -122,9 +120,8 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
extensions?: DesktopExtension.Transport
extensionPlugins?: readonly Plugin.Definition[]
extensionManager?: ExtensionManager.Transport
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
@@ -0,0 +1,158 @@
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
type Server = ReturnType<typeof useServer>
export type BrowserAttachment = {
registration?: BrowserPaneRegistration
browser: BrowserPaneState
error?: string
}
type Live = {
server: Server
sessionID: string
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
tab?: string
registration?: BrowserPaneRegistration
retry?: ReturnType<typeof setTimeout>
attempts: number
dispose: () => void
}
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
name: "BrowserAttachments",
gate: false,
init: () => {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
)
const close = (id: string) => {
live.get(id)?.dispose()
live.delete(id)
setStore(id, undefined)
}
createEffect(() => {
const on = enabled()
const tabs = shellTabs.store
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
Object.keys(store).forEach((id) => {
const entry = live.get(id)
if (!entry) return
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
if (current) entry.tab = tabKey(current)
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
if (on && owned && !entry.server.health?.incompatible) return
close(id)
})
})
onCleanup(() => Array.from(live.keys()).forEach(close))
return {
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
live.set(id, entry)
setStore(id, { browser: null })
const register = () => {
if (entry.registration || live.get(id) !== entry) return
// The server's shared transport follows a restarted sidecar's port whether or not any route
// for this session is mounted; the connection captured at attach time may predate it.
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
const registration = pane.register({ sessionID, endpoint }, (event) => {
if (live.get(id) !== entry) return
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
if (event.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
return close(id)
}
if (event.error === "browser.pane.replaced") {
registration.close()
entry.registration = undefined
return setStore(id, {
registration: undefined,
browser: null,
error: language.t("session.browser.replaced"),
})
}
// The desktop dropped the attachment (server restart, attach race). Re-register so the
// agent's browser tool comes back without a reload.
if (event.error === "browser.pane.registration.closed") {
registration.close()
entry.registration = undefined
setStore(id, { registration: undefined, browser: null, error: undefined })
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
return
}
if (event.state) entry.attempts = 0
batch(() => {
setStore(id, "browser", reconcile(event.state))
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
})
})
entry.registration = registration
setStore(id, { registration, browser: null, error: undefined })
}
// A new session appears in the UI before its server-side creation finishes. The listener
// belongs to this provider, not to the route effect that happened to call attach().
const data = server.ctx.data
const unsubscribe = runWithOwner(owner, () =>
data.on("session.created", (event) => {
if (event.data.sessionID === sessionID) register()
}),
)
if (!data.session.creating(sessionID)) register()
entry.dispose = () => {
unsubscribe?.()
clearTimeout(entry.retry)
entry.registration?.close()
}
},
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
const id = key(server, sessionID)
const listeners = focus.get(id) ?? new Set()
listeners.add(listener)
focus.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) focus.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
const registration = live.get(key(server, sessionID))?.registration
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
return registration.command(command)
},
}
},
})
+115
View File
@@ -0,0 +1,115 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { useServer } from "@/runtime/server/current"
import type { SessionModel } from "../model"
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
import { useBrowserAttachments } from "./attachments"
export function createSessionBrowser(session: SessionModel) {
const attachments = useBrowserAttachments()
const language = useLanguage()
const server = useServer()
const [local, setLocal] = createStore({ error: undefined as string | undefined })
const attachment = () => {
const sessionID = session.identity.sessionID()
return sessionID ? attachments.state(server, sessionID) : undefined
}
const available = createMemo(
() =>
attachments.enabled() &&
attachments.supported(server) &&
!!session.identity.sessionID() &&
!server.health?.incompatible &&
session.isDesktop(),
)
const attached = () => attachment()?.registration !== undefined
const browserTabs = createMemo(
() =>
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
setLocal("error", undefined)
const owner = session.ownership.capture()
void attachments.command(server, sessionID, command).catch(() => {
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
})
}
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
})
createEffect(
on(
() => session.layout.tabs().active(),
(active) => {
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
},
),
)
createEffect(
on(
() => session.layout.tabs().all(),
(current, previous) => {
previous
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
.forEach((key) => {
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
if (tab) command({ type: "tabs.close", tabID: tab.id })
})
},
),
)
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
createEffect(
on(
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
(ids, previous) => {
if (!ids) return
const known = new Set(previous ?? [])
batch(() => {
const tabs = session.layout.tabs()
tabs
.all()
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
.forEach(tabs.close)
const current = tabs.all()
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
if (added.length) tabs.setAll([...current, ...added])
})
},
),
)
return {
available,
attached,
opened: () => attached() && browserTabs().length > 0,
state: () => attachment()?.browser ?? null,
tabs: browserTabs,
active: () =>
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
browserTabs()[0],
error: () => local.error ?? attachment()?.error,
registration: () => attachment()?.registration,
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open: () => command({ type: "tabs.open" }),
command,
}
}
+185
View File
@@ -0,0 +1,185 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Loader } from "@opencode/ui/loader"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: {
registration: BrowserPaneRegistration
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const state = props.browser.active
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const measure = () => {
if (!surface) return
const tab = state()
if (!tab) {
props.registration.setLayout()
return
}
const rect = surface.getBoundingClientRect()
const zoom = platform.webviewZoom?.() ?? 1
const left = Math.round(rect.left * zoom)
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
if (next !== layout) {
layout = next
// Let the browser resolve the semantic backdrop color, including custom
// themes using color formats that Electron's color parser cannot read.
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
props.registration.setLayout({
tabID: tab.id,
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
}
const tick = () => {
frame = undefined
measure()
if (performance.now() < until) frame = requestAnimationFrame(tick)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(
on(
[
() => platform.webviewZoom?.(),
() => dialog.active,
() => store.visible,
() => props.visible,
() => state()?.id,
],
() => schedule(300),
),
)
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
// animation frame keeps the native view in step with a pane drag.
createResizeObserver(() => surface, measure)
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
const portals = new MutationObserver(() => schedule(300))
portals.observe(document.body, { childList: true })
onCleanup(() => portals.disconnect())
const appearance = new MutationObserver(() => schedule(300))
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => appearance.disconnect())
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
props.registration.setLayout()
})
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: direction, tabID: tab.id })
}}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
}}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
}
/>
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onInput={(event) => setStore("address", event.currentTarget.value)}
/>
</form>
</div>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
</aside>
)
}
+2 -6
View File
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
@@ -62,14 +62,10 @@ export function createActiveSessionRegion(input: {
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
syncPromptModel(local, prompt)
})
createEffect(
@@ -8,7 +8,6 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { Tabs } from "@opencode/ui/tabs"
import { IconButton } from "@opencode/ui/icon-button"
import { Icon } from "@opencode/ui/icon"
import { Loader } from "@opencode/ui/loader"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Mark } from "@opencode/ui/logo"
import { Keybind } from "@opencode/ui/keybind"
@@ -22,6 +21,8 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const browserTabID = "session-side-panel-browser-tab"
const browserTabPanelID = "session-side-panel-browser-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
@@ -35,6 +36,8 @@ import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
isSessionBrowserTab,
sessionBrowserTab,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
@@ -43,9 +46,8 @@ import {
import { setSessionHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
import type { SessionExtensions } from "@/extensions/session"
import { isExtensionTab } from "@/extensions/keys"
import { ExtensionPanelContent } from "@/extensions/content"
import { SessionBrowserPane } from "@/session/browser/pane"
import type { createSessionBrowser } from "@/session/browser/model"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
@@ -56,7 +58,6 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
}
export function SessionSidePanel(props: {
extensions: SessionExtensions
canReview: boolean
diffs: ReviewDiff[]
diffsReady: boolean
@@ -71,6 +72,7 @@ export function SessionSidePanel(props: {
reviewPresent?: boolean
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
}) {
const layout = useLayout()
const settings = useSettings()
@@ -79,7 +81,6 @@ export function SessionSidePanel(props: {
const command = useCommand()
const sdk = useWorkspaceLocation()
const { sessionKey, tabs, view, params } = useSessionLayout()
const extensions = props.extensions
const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -172,9 +173,7 @@ export function SessionSidePanel(props: {
review: reviewTab,
hasReview: () => props.canReview,
fileBrowser: () => true,
extensions: extensions.keys,
defaultPanel: extensions.defaultPanel,
canClose: extensions.canClose,
browser: props.browser.attached,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
@@ -226,12 +225,7 @@ export function SessionSidePanel(props: {
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return (
active !== "review" &&
active !== "context" &&
active !== "empty" &&
!extensions.keys().includes(active)
)
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
@@ -371,22 +365,21 @@ export function SessionSidePanel(props: {
/>
}
>
<Match when={extensions.keys().includes(tab)}>
<Show when={extensions.panels().find((panel) => panel.key === tab)}>
{(panel) => (
<Match when={isSessionBrowserTab(tab)}>
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
{(item) => (
<SortableTab
tab={tab}
index={tabs().all().indexOf(tab)}
onTabClose={panel().props.closable === false ? undefined : tabs().close}
onTabClose={() => props.browser.close(item().id)}
id={`${browserTabID}-${item().id}`}
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<Show when={panel().props.loading} fallback={panel().icon()}>
<Loader />
</Show>
<span class="max-w-40 truncate" dir="auto">
{panel().props.title}
<Icon name="window-cursor" size="small" />
<span class="max-w-40 truncate">
{item().title || language.t("session.tab.browser")}
</span>
<Show when={panel().props.badge}>{panel().props.badge}</Show>
</div>
</SortableTab>
)}
@@ -443,7 +436,7 @@ export function SessionSidePanel(props: {
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{/* With only files to add, the plus stays a one-click "Open file" button. */}
<Show
when={extensions.hasActions()}
when={props.browser.available()}
fallback={
<Tooltip
value={
@@ -498,7 +491,12 @@ export function SessionSidePanel(props: {
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
{extensions.actions()}
<Menu.Item onSelect={props.browser.open}>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -554,7 +552,29 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Show>
<ExtensionPanelContent panels={extensions.panels()} active={activeTab()} />
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
role="tabpanel"
aria-labelledby={
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
}
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
inert={!isSessionBrowserTab(activeTab()) || undefined}
>
<Show when={props.browser.registration()} keyed>
{(registration) => (
<SessionBrowserPane
registration={registration}
browser={props.browser}
visible={isSessionBrowserTab(activeTab())}
/>
)}
</Show>
</div>
</Show>
<Show when={fileBrowserMounted()}>
<div
+20 -22
View File
@@ -13,7 +13,7 @@ export function SortableTab(props: {
tab: string
index: number
temporary?: boolean
onTabClose?: (tab: string) => void
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
/** Replaces the file visual for non-file tabs such as the browser. */
children?: JSX.Element
@@ -46,29 +46,27 @@ export function SortableTab(props: {
value={props.tab}
id={props.id}
aria-controls={props.ariaControls}
onMiddleClick={props.onTabClose ? () => props.onTabClose?.(props.tab) : undefined}
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
closeButton={
<Show when={props.onTabClose}>
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose?.(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
</Show>
<Tooltip
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<Keybind keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
+13 -13
View File
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
@@ -10,7 +12,6 @@ import {
getTabReorderIndex,
shouldShowFileTree,
} from "./helpers"
import { extensionTabKey } from "@/extensions/keys"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled file tree", () => {
@@ -235,36 +236,35 @@ describe("createSessionTabs", () => {
})
})
test("exposes one extension panel without treating it as a file tab", () => {
test("exposes one browser tab without treating it as a file tab", () => {
createRoot((dispose) => {
const panel = extensionTabKey("example", "panel")
const tabs = createMemo(() => ({ active: () => panel, all: () => [panel] }))
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
extensions: () => [panel],
browser: () => true,
})
expect(result.panelTabs()).toEqual([panel])
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
expect(result.openedTabs()).toEqual([])
expect(result.activeTab()).toBe(panel)
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(panel)
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
dispose()
})
})
test("keeps extension panels in layout order beside files and drops unavailable contributions", () => {
const first = extensionTabKey("example", "first")
const second = extensionTabKey("example", "second")
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
const first = sessionBrowserTab("first")
const second = sessionBrowserTab("second")
const input = {
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
normalizeTab: (tab: string) => tab,
}
createRoot((dispose) => {
const result = createSessionTabs({ ...input, extensions: () => [first, second] })
const result = createSessionTabs({ ...input, browser: () => true })
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe(second)
@@ -273,7 +273,7 @@ describe("createSessionTabs", () => {
dispose()
})
createRoot((dispose) => {
const result = createSessionTabs({ ...input, extensions: () => [] })
const result = createSessionTabs({ ...input, browser: () => false })
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe("file://src/a.ts")
expect(result.closableTab()).toBe("file://src/a.ts")
+57 -29
View File
@@ -1,11 +1,15 @@
import { batch, createMemo, type Accessor } from "solid-js"
import { createSizing } from "@opencode/ui/resize-state"
export { createSizing }
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/runtime/persistence/equality"
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { isExtensionTab } from "@/extensions/keys"
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export {
SESSION_BROWSER_TAB,
SESSION_OPEN_FILE_TAB,
sessionBrowserTab,
isSessionBrowserTab,
} from "@/shell/state/session-tabs"
const emptyTabs: string[] = []
@@ -21,9 +25,7 @@ type TabsInput = {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
extensions?: Accessor<readonly string[]>
defaultPanel?: Accessor<string | undefined>
canClose?: (key: string) => boolean
browser?: Accessor<boolean>
}
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
@@ -34,6 +36,7 @@ export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const browser = input.browser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openFileOpen = createMemo(
() =>
@@ -47,10 +50,8 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (input.extensions?.().includes(tab)) return [tab]
if (tab === "context" || tab === "review") return []
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
if (tab !== SESSION_OPEN_FILE_TAB && !input.pathFromTab(tab)) return []
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
@@ -62,33 +63,20 @@ export const createSessionTabs = (input: TabsInput) => {
{ equals: same },
)
const openedTabs = createMemo(
() =>
Array.from(
new Set(
input
.tabs()
.all()
.filter((tab) => !!input.pathFromTab(tab))
.map(input.normalizeTab),
),
),
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
emptyTabs,
{
equals: same,
},
{ equals: same },
)
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active && input.extensions?.().includes(active)) return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
const preferred = input.defaultPanel?.()
if (preferred) return preferred
if (contextOpen()) return "context"
if (review() && hasReview()) return "review"
return "empty"
@@ -100,9 +88,9 @@ export const createSessionTabs = (input: TabsInput) => {
})
const closableTab = createMemo<string | undefined>(() => {
const active = activeTab()
if (active && input.extensions?.().includes(active)) return input.canClose?.(active) === false ? undefined : active
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (!openedTabs().includes(active)) return
return active
})
@@ -184,4 +172,44 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
return toIndex
}
export const createSizing = () => {
const [state, setState] = createStore({ active: false })
let t: number | undefined
const stop = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", false)
}
const start = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", true)
}
onMount(() => {
makeEventListener(window, "pointerup", stop)
makeEventListener(window, "pointercancel", stop)
makeEventListener(window, "blur", stop)
})
onCleanup(() => {
if (t !== undefined) clearTimeout(t)
})
return {
active: () => state.active,
start,
touch() {
start()
t = window.setTimeout(stop, 120)
},
}
}
export type Sizing = ReturnType<typeof createSizing>
+7 -16
View File
@@ -7,6 +7,7 @@ import { useData } from "@/runtime/server/current"
import { same } from "@/runtime/persistence/equality"
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { projectForSession } from "@/shell/layout/helpers"
import { useBrowserAttachments } from "./browser/attachments"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -18,7 +19,6 @@ import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
import { useOptionalDesktopExtensions } from "@/extensions/provider"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@@ -29,7 +29,7 @@ export function useSessionModel() {
const data = useData()
const server = useServer()
const shellTabs = useTabs()
const extensions = useOptionalDesktopExtensions()
const attachments = useBrowserAttachments()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -83,21 +83,12 @@ export function useSessionModel() {
normalizeTab,
review: isDesktop,
hasReview: canReview,
extensions: () =>
extensions?.state.panels
.filter((panel) => panel.session.sessionID === sessionID() && panel.session.server.id === server.key)
.map((panel) => panel.key) ?? [],
defaultPanel: () =>
extensions?.state.panels.find(
(panel) =>
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.props.default,
)?.key,
canClose: (key) =>
extensions?.state.panels.find(
(panel) =>
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.key === key,
)?.props.closable !== false,
fileBrowser: () => isDesktop() && !!sessionID(),
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
browser: () => {
const id = sessionID()
return !!id && attachments.state(server, id)?.registration !== undefined
},
})
return {
+3 -3
View File
@@ -13,7 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { SessionExtensions } from "@/extensions/session"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
@@ -147,13 +147,12 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
export function SessionDesktopReview(props: {
review: SessionReviewModel
extensions: SessionExtensions
browser: ReturnType<typeof createSessionBrowser>
present?: boolean
}) {
return (
<Suspense>
<SessionSidePanel
extensions={props.extensions}
canReview={props.review.canReview()}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
@@ -174,6 +173,7 @@ export function SessionDesktopReview(props: {
reviewPresent={props.present}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
/>
</Suspense>
)
+3 -13
View File
@@ -33,8 +33,7 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { useExtensionPanels } from "@/extensions/session"
import { createSessionServices } from "@/extensions/workspace"
import { createSessionBrowser } from "./browser/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -49,14 +48,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const extensions = useExtensionPanels({
services: createSessionServices(session),
active: session.tabs.activeTab,
serverID: () => server.key,
sessionID: session.identity.sessionID,
tabs: session.layout.tabs,
open: () => session.layout.view().reviewPanel.open(),
})
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -257,7 +249,6 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
headerActions={extensions.header()}
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
@@ -297,7 +288,6 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
<Show when={isDesktop()}>{extensions.declarations()}</Show>
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
@@ -389,7 +379,7 @@ export function SessionScreen(props: { session: SessionModel }) {
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview review={review} extensions={extensions} present={store.sideReviewPresent} />
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
</div>
</Show>
</div>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
@@ -54,8 +54,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
variant: { current: () => "high" },
},
},
{
@@ -77,8 +76,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
variant: { current: () => model.variant },
},
},
{
@@ -93,54 +91,26 @@ describe("syncPromptModel", () => {
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
describe("stale prompt model", () => {
test("replaces the submission mirror without changing the effective selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
syncPromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
set: (value) => calls.push(value),
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
})
})
@@ -12,10 +12,8 @@ type Local = {
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
@@ -51,18 +49,3 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
+74 -4
View File
@@ -1,6 +1,76 @@
import { AuxiliaryPanel } from "@opencode/ui/auxiliary-panel"
import type { ComponentProps } from "solid-js"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import type { ParentProps } from "solid-js"
export function TerminalSurface(props: ComponentProps<typeof AuxiliaryPanel>) {
return <AuxiliaryPanel {...props} id="terminal-panel" data-component="terminal-panel" />
export function TerminalSurface(
props: ParentProps<{
label: string
opened: boolean
present?: boolean
framed?: boolean
embedded?: boolean
desktop: boolean
stacked: boolean
height: string
contentHeight: string
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
ref?: (element: HTMLElement) => void
}>,
) {
return (
<aside
ref={props.ref}
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
inert={!props.opened}
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
classList={{
"w-full": !props.desktop || props.stacked,
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
"will-change-[height]": !props.embedded && !props.resizing && (!props.desktop || props.stacked),
}}
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
>
<div
classList={{ "md:hidden": !props.stacked, hidden: props.stacked || props.embedded }}
onPointerDown={props.onResizeStart}
>
<ResizeHandle
class="-top-1"
direction="vertical"
size={props.pane}
min={100}
max={props.max}
collapseThreshold={50}
onResize={props.onResize}
onCollapse={props.onCollapse}
/>
</div>
<div
data-slot="terminal-panel-content"
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": props.opened && !props.desktop && !props.embedded,
"pointer-events-none": !props.opened,
}}
style={{ height: props.contentHeight }}
>
{props.children}
</div>
</aside>
)
}
@@ -335,7 +335,6 @@ export function SessionSummaryPanel(props: {
}
type MessageTimelineProps = {
headerActions?: JSX.Element
hideHeader?: boolean
session: TimelineSessionSource
background: SessionBackground
@@ -796,7 +795,6 @@ function MessageTimelineView(
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
{props.headerActions}
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
@@ -2,17 +2,18 @@ import { Component, Show } from "solid-js"
import { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
import { ExtensionSlot } from "@/extensions/slot"
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
return (
<>
@@ -30,7 +31,22 @@ export const SettingsExperimental: Component = () => {
<div class="settings-tab-body">
<div class="settings-section">
<SettingsList>
<ExtensionSlot path="settings.experimental" />
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
description={language.t("settings.general.row.browserPane.description")}
>
<div data-action="settings-experimental-browser">
<Switch
checked={settings.general.experimentalBrowser()}
onChange={settings.general.setExperimentalBrowser}
hideLabel
>
{language.t("settings.general.row.browserPane.title")}
</Switch>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
@@ -1,71 +0,0 @@
.desktop-extension-manager {
display: flex;
flex-direction: column;
gap: 32px;
}
.desktop-extension-install {
gap: 14px;
}
.desktop-extension-drop {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
color: var(--v2-text-text-muted);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
transition: 120ms ease-out;
}
.desktop-extension-drop[data-dragging="true"] {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-text-text-base);
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
}
.desktop-extension-drop > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4px;
}
.desktop-extension-drop strong {
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
font-weight: 590;
}
.desktop-extension-drop span,
.desktop-extension-empty {
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
.desktop-extension-url {
display: flex;
align-items: center;
gap: 8px;
}
.desktop-extension-url [data-component="text-input-v2"] {
min-width: 0;
flex: 1;
}
.desktop-extension-method-label {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 500;
}
.desktop-extension-error {
color: var(--v2-state-fg-danger);
font-size: 13px;
line-height: var(--line-height-base);
}
.desktop-extension-title {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.desktop-extension-empty {
padding: 20px;
}
@@ -1,239 +0,0 @@
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Badge } from "@opencode/ui/badge"
import { Button } from "@opencode/ui/button"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { SettingsRow } from "@opencode/ui/layout"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
import { useDesktopExtensions } from "@/extensions/provider"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import "./extensions.css"
const errors = {
invalidArchive: "settings.desktopExtensions.error.archive",
invalidManifest: "settings.desktopExtensions.error.manifest",
invalidPath: "settings.desktopExtensions.error.path",
tooLarge: "settings.desktopExtensions.error.size",
reserved: "settings.desktopExtensions.error.reserved",
notFound: "settings.desktopExtensions.error.notFound",
disabled: "settings.desktopExtensions.error.disabled",
invalidModule: "settings.desktopExtensions.error.module",
download: "settings.desktopExtensions.error.download",
url: "settings.desktopExtensions.error.url",
storage: "settings.desktopExtensions.error.storage",
files: "settings.desktopExtensions.error.files",
} as const
/** Port of OCDX's manager, backed by the native Desktop extension registry. */
export function SettingsExtensions() {
const host = useDesktopExtensions()
const language = useLanguage()
const platform = usePlatform()
const [state, setState] = createStore({
url: "",
busy: false,
dragging: false,
error: undefined as keyof typeof errors | undefined,
})
let picker: HTMLInputElement | undefined
const entries = createMemo(() =>
[
...host.builtins().map((plugin) => ({
id: plugin.id,
name: plugin.name ? (language.t(plugin.name as Parameters<typeof language.t>[0]) ?? plugin.name) : plugin.id,
version: plugin.version ?? platform.version ?? "0.0.0",
enabled: true,
builtin: true,
hasMain: plugin.main ?? false,
})),
...host.state.installed
.filter((entry) => !host.builtins().some((plugin) => plugin.id === entry.id))
.map((entry) => ({ ...entry, builtin: false })),
].toSorted((a, b) => Number(b.builtin) - Number(a.builtin) || a.name.localeCompare(b.name)),
)
const error = () => state.error ?? host.state.managerError
const failed = () => host.state.installed.find((entry) => host.state.failures[entry.id])
const perform = async (action: (manager: ExtensionManager.Transport) => Promise<unknown>) => {
if (state.busy || !host.manager) return false
setState({ busy: true, error: undefined })
try {
await action(host.manager)
return true
} catch (error) {
setState("error", error instanceof ExtensionManager.ManagerError ? error.code : "storage")
return false
} finally {
setState("busy", false)
}
}
const installFiles = async (files: FileList | File[]) => {
const archives = Array.from(files).filter((file) => file.name.toLowerCase().endsWith(".ocdx"))
if (!archives.length) {
setState("error", "files")
return
}
await perform(async (manager) => {
for (const file of archives) await manager.install(new Uint8Array(await file.arrayBuffer()))
})
}
const installURL = async () => {
const url = state.url.trim()
if (!url) return
if (await perform((manager) => manager.installURL(url))) setState("url", "")
}
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
</div>
</div>
<div class="settings-tab-body desktop-extension-manager" data-component="desktop-extension-manager">
<section class="settings-section desktop-extension-install">
<h3 class="settings-section-title">{language.t("settings.desktopExtensions.installTitle")}</h3>
<div
class="desktop-extension-drop"
data-dragging={state.dragging}
onDragEnter={(event) => {
event.preventDefault()
setState("dragging", true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return
setState("dragging", false)
}}
onDrop={(event) => {
event.preventDefault()
setState("dragging", false)
if (event.dataTransfer) void installFiles(event.dataTransfer.files)
}}
>
<div>
<strong>{language.t("settings.desktopExtensions.drop")}</strong>
<span>{language.t("settings.desktopExtensions.choose")}</span>
</div>
<Button size="small" variant="neutral" disabled={state.busy} onClick={() => picker?.click()}>
{language.t("settings.desktopExtensions.browse")}
</Button>
<input
ref={picker}
type="file"
accept=".ocdx"
multiple
hidden
aria-label={language.t("settings.desktopExtensions.files")}
onChange={(event) => {
if (event.currentTarget.files) void installFiles(event.currentTarget.files)
event.currentTarget.value = ""
}}
/>
</div>
<div class="desktop-extension-method-label">{language.t("settings.desktopExtensions.fromURL")}</div>
<div class="desktop-extension-url">
<TextInput
appearance="large"
dir="ltr"
value={state.url}
placeholder={language.t("settings.desktopExtensions.placeholder")}
aria-label={language.t("settings.desktopExtensions.url")}
disabled={state.busy}
onInput={(event) => setState("url", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void installURL()
}}
/>
<Button
size="normal"
variant="neutral"
disabled={state.busy || !state.url.trim()}
onClick={() => void installURL()}
>
{language.t("settings.desktopExtensions.install")}
</Button>
</div>
</section>
<Show when={error()}>
{(error) => (
<div role="alert" class="desktop-extension-error">
{language.t(errors[error()])}
</div>
)}
</Show>
<Show when={failed()}>
{(entry) => (
<div role="alert" class="desktop-extension-error">
{language.t("settings.desktopExtensions.error.activation", { name: entry().name })}
</div>
)}
</Show>
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.desktopExtensions.installed")}</h3>
<div data-component="settings-list">
<Show
when={host.state.managerReady}
fallback={
<div role="status" class="desktop-extension-empty">
{language.t("common.loading")}
</div>
}
>
<Show
when={entries().length}
fallback={<div class="desktop-extension-empty">{language.t("settings.desktopExtensions.empty")}</div>}
>
<For each={entries()}>
{(entry) => (
<SettingsRow
title={
<span class="desktop-extension-title">
<bdi dir="auto">{entry.name}</bdi>
<Badge variant="neutral">v{entry.version}</Badge>
<Show when={entry.builtin}>
<Badge variant="accent">{language.t("settings.desktopExtensions.builtin")}</Badge>
</Show>
<Show when={entry.hasMain}>
<Badge variant="neutral">{language.t("settings.desktopExtensions.main")}</Badge>
</Show>
</span>
}
description={<bdi dir="ltr">{entry.id}</bdi>}
>
<div class="flex items-center gap-3">
<Show when={!entry.builtin}>
<Tooltip value={language.t("settings.desktopExtensions.reload", { name: entry.name })}>
<IconButton
icon={<Icon name="reset" />}
variant="ghost-muted"
size="normal"
disabled={state.busy || !entry.enabled}
aria-label={language.t("settings.desktopExtensions.reload", { name: entry.name })}
onClick={() => void perform((manager) => manager.reload(entry.id))}
/>
</Tooltip>
</Show>
<Switch
checked={entry.enabled}
disabled={entry.builtin || state.busy}
onChange={(enabled) => void perform((manager) => manager.enable(entry.id, enabled))}
hideLabel
>
{language.t("settings.desktopExtensions.enable", { name: entry.name })}
</Switch>
</div>
</SettingsRow>
)}
</For>
</Show>
</Show>
</div>
</section>
</div>
</>
)
}
@@ -20,7 +20,7 @@ interface PluginRowItem {
name: string
}
export const SettingsTools: Component = () => {
export const SettingsExtensions: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const data = useData()
@@ -60,8 +60,8 @@ export const SettingsTools: Component = () => {
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.tools")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.tools.description")}</span>
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
+20 -1
View File
@@ -1 +1,20 @@
export { SettingsRow } from "@opencode/ui/layout"
import type { Component, JSX } from "solid-js"
import "@/settings/settings.css"
export interface SettingsRowProps {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{props.title}</div>
<div data-slot="settings-row-description">{props.description}</div>
</div>
<div data-slot="settings-row-control">{props.children}</div>
</div>
)
}
+13 -28
View File
@@ -15,9 +15,7 @@ import { SettingsModels } from "./models/models"
import { SettingsServers } from "./servers/servers"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsTools } from "./providers/tools"
import { SettingsExtensions } from "./extensions/extensions"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsAbout } from "./about/about"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode/ui/context/dialog"
@@ -43,21 +41,14 @@ const sections = [
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "tools", icon: "extensions", label: "settings.tab.tools" },
],
[
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
{ value: "experimental", icon: "flask", label: "settings.tab.experimental" },
],
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
[{ value: "about", icon: "info", label: "settings.tab.about" }],
] as const
export const SettingsScreen: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const visibleSections = createMemo(() =>
sections.map((group) => group.filter((section) => section.value !== "extensions" || !!platform.extensionManager)),
)
const dialog = useDialog()
const surface = useSettingsSurface()
const layout = useLayout()
@@ -65,9 +56,6 @@ export const SettingsScreen: Component = () => {
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
createEffect(() => {
if (!platform.extensionManager && surface.tab() === "extensions") surface.open("tools")
})
let root: HTMLDivElement | undefined
onMount(() => {
@@ -140,9 +128,8 @@ export const SettingsScreen: Component = () => {
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
visibleSections()
.flat()
.find((section) => section.value === surface.tab())?.label ?? "settings.tab.preferences",
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
@@ -153,7 +140,7 @@ export const SettingsScreen: Component = () => {
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={visibleSections()}>
<For each={sections}>
{(group, index) => (
<>
<Show when={index() > 0}>
@@ -189,9 +176,9 @@ export const SettingsScreen: Component = () => {
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
<For each={visibleSections()}>
<For each={sections}>
{(group) => (
<div data-slot="settings-nav-group" class="flex flex-col gap-1 w-full">
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
@@ -227,11 +214,6 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Show when={platform.extensionManager}>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</Show>
<Tabs.Content value="servers" class="settings-panel">
<SettingsServers />
</Tabs.Content>
@@ -240,7 +222,10 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces activeDirectory={directory()} resetProjectFilter={() => state.worktreeFilterReset} />
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
@@ -248,8 +233,8 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="tools" class="settings-panel">
<SettingsTools />
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
@@ -67,7 +67,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.tools")}
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
</Tabs.List>
@@ -147,8 +147,8 @@ export const ProjectSettingsExtensions: Component = () => {
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.tools")}</h2>
<span>{language.t("project.settings.tools.description")}</span>
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
@@ -458,10 +458,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const config = keybindConfig(id)
return config ? formatKeybindParts(config, language.t) : []
},
matches(id: string, event: KeyboardEvent) {
const config = keybindConfig(id)
return !!config && matchKeybind(parseKeybind(config), event)
},
show: showPalette,
keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
+4 -7
View File
@@ -4,8 +4,7 @@ import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { DesktopExtensionsProvider } from "@/extensions/provider"
import { ExtensionSlot } from "@/extensions/slot"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
@@ -80,11 +79,9 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopExtensionsProvider>
<ExtensionSlot path="app">
<Shell>{props.children}</Shell>
</ExtensionSlot>
</DesktopExtensionsProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
+1 -1
View File
@@ -52,7 +52,7 @@ export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
export type ReviewPanelSource = string
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
@@ -1,4 +1,8 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
export const isSessionBrowserTab = (tab: string | undefined) =>
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
export type SessionTabs = {
active?: string
@@ -1,7 +1,6 @@
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { ExtensionSlot } from "@/extensions/slot"
type Registration = {
active: () => boolean
@@ -46,9 +45,7 @@ export function TitlebarRightMount(props: { vertical?: boolean }) {
ref={slot.setMount}
id="opencode-titlebar-right"
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
>
<ExtensionSlot path="titlebar.actions" />
</div>
/>
)
}
+1 -1
View File
@@ -257,7 +257,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state
}
function withPath<Value>(input: string, action: (file: string) => Value): Value {
function withPath(input: string, action: (file: string) => unknown) {
return action(path.normalize(input))
}
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
@@ -0,0 +1,459 @@
import { afterEach, expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Agent } from "@/runtime/server/types"
import type { ModelKey } from "@/providers/models/selection"
import { ServerScope } from "@/runtime/server/scope"
// Bun does not compile Solid JSX. Compile the real context provider with the
// same presets as Vite instead of replacing LocalProvider's implementation.
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "selection-solid-context",
setup(build) {
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
type Event = { data: { sessionID: string } }
type ConfigModel = string | { providerID: string; model: string; variant?: string }
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
agent,
model: { providerID: "provider", id: modelID, variant },
})
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
name,
mode: "primary",
permission: [],
options: {},
model,
variant,
})
let active: ReturnType<typeof fixture>
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
mock.module("@/settings/model", () => ({
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
}))
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
openExternal() {},
restart: async () => {},
notify: async () => {},
}),
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
const cleanups: Array<() => void> = []
afterEach(() =>
cleanups
.splice(0)
.reverse()
.forEach((dispose) => dispose()),
)
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
const directory = `/selection-test/${crypto.randomUUID()}`
const [state, set] = createStore({
visible: true,
configLoaded: true,
connection: "connected",
route: { id: "ses_a" as string | undefined },
agents: input.agents ?? [agent("build"), agent("plan")],
config: input.config as ConfigModel | undefined,
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
models: ["a", "b", "c"].map((id) => ({
id,
modelID: id,
providerID: "provider",
name: `Model ${id}`,
settings: {},
headers: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8192 },
})),
})
const [preferences, setPreferences] = createStore({
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
recent: [] as ModelKey[],
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
})
const events = new Map<string, Set<(event: Event) => void>>()
const configLoads: string[] = []
const result = {
prompt: createMemoryComposerState(),
directory,
state,
set,
setPreferences,
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
data: {
session: { get: (id: string) => state.sessions[id] },
location: {
agent: { list: () => state.agents },
config: {
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
sync: async () => {
configLoads.push(state.connection)
},
},
provider: { list: () => state.providers },
model: { list: () => state.models },
integration: { list: () => [] },
},
},
sdk: {
scope: ServerScope.local,
connection: { status: () => state.connection },
event: {
on(type: string, handler: (event: Event) => void) {
const handlers = events.get(type) ?? new Set()
events.set(type, handlers)
handlers.add(handler)
return () => handlers.delete(handler)
},
},
},
emit(type: string, sessionID = "ses_a") {
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
},
configLoads,
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
return createComponent(LocalProvider, {
get children() {
local = useLocal()
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
return null
},
})
},
})
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
return result
}
function selection(local: ReturnType<typeof useLocal>) {
return {
agent: local.agent.current()?.name,
model: local.model.current()?.id,
variant: local.model.variant.current(),
}
}
test("restores durable agents even when the agent selector is hidden", () => {
const f = fixture({ session: durable("b", "high", "plan") })
f.set("visible", false)
const { local } = f.mount()
expect(local.agent.visible()).toBe(false)
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("waits for initial configuration and reloads it after reconnecting", () => {
const f = fixture({ config: "provider/b" })
f.set("configLoaded", false)
const { local } = f.mount()
expect(local.model.ready()).toBe(false)
expect(local.model.current()).toBeUndefined()
f.set("connection", "reconnecting")
f.set("connection", "connected")
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
f.set("configLoaded", true)
expect(local.model.ready()).toBe(true)
expect(local.model.current()?.id).toBe("b")
})
test("new-session promotion does not mask a command's durable overrides", () => {
const f = fixture({ session: durable("a", "low") })
const { local } = f.mount()
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
f.set("sessions", "ses_a", durable("b", "high", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
f.set("route", "id", undefined)
const { local, composer } = f.mount(true)
if (!composer) throw new Error("missing draft composer")
composer.set(key("c"))
composer.variant.set("high")
local.agent.set("plan")
expect(composer.current()?.id).toBe("b")
expect(composer.variant.current()).toBeUndefined()
local.agent.set("build")
expect(composer.current()?.id).toBe("c")
expect(composer.variant.current()).toBe("high")
local.agent.set("plan")
const choices = composer.remembered()
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
f.set("route", "id", "ses_a")
local.agent.set("build")
expect(local.model.current()?.id).toBe("c")
})
test("session model picks snapshot the variant rather than following another session's preferences", () => {
const f = fixture({ session: durable("a") })
const { local } = f.mount()
f.setPreferences("variant", "provider/b", "low")
local.model.set(key("b"))
f.setPreferences("variant", "provider/b", "high")
expect(local.model.variant.current()).toBe("low")
})
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.agent.set("plan")
local.model.variant.set("low")
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
f.set("sessions", "ses_b", durable("b", "high"))
f.set("route", "id", "ses_b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("restores each agent's model and variant after provider remount", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
const first = f.mount()
first.local.model.variant.set("high")
first.local.agent.set("plan")
first.local.model.set(key("c"))
first.local.model.variant.set("low")
first.dispose()
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test("changing models drops the old variant even when both models support it", () => {
const f = fixture({ session: durable("a", "high") })
const { local } = f.mount()
local.model.set(key("b"))
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
f.setPreferences("variant", "provider/c", "low")
local.model.set(key("c"))
expect(local.model.variant.current()).toBe("low")
})
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
const f = fixture({
session: durable("a", "low", "plan"),
agents: [agent("build"), agent("plan", key("b"), "high")],
config: { providerID: "provider", model: "c", variant: "high" },
preferred: "high",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("uses historical message selection only when durable and local selection are absent", () => {
const f = fixture()
const { local } = f.mount()
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
local.model.variant.set("high")
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
const f = fixture({
session: durable("removed", "high"),
agents: [agent("build", key("b"), "low")],
config: "provider/c",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("agents", [agent("build", key("removed"))])
expect(local.model.current()?.id).toBe("c")
f.setPreferences("recent", [key("removed"), key("b")])
f.set("config", "provider/removed")
expect(local.model.current()?.id).toBe("b")
f.setPreferences("recent", [key("removed")])
expect(local.model.current()?.id).toBe("a")
f.set("providers", [])
expect(local.model.current()).toBeUndefined()
})
test("global and agent model/variant defaults react to configuration replacement", () => {
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("config", { providerID: "provider", model: "b", variant: "high" })
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("agents", [agent("build", key("a"), "low")])
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("agents", [agent("build", key("c"), "high")])
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
f.set("agents", [agent("build")])
f.set("config", "provider/b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
})
test("durable and explicitly selected Default override a saved variant preference", () => {
const f = fixture({
session: durable("a"),
preferred: "high",
config: { providerID: "provider", model: "a", variant: "low" },
})
const { local } = f.mount()
expect(local.model.variant.current()).toBeUndefined()
local.model.variant.set(undefined)
expect(f.preferences.store.variant["provider/a"]).toBe("default")
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_a")
f.setPreferences("variant", "provider/a", "high")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBe("high")
f.set("route", "id", "ses_a")
expect(local.model.variant.current()).toBeUndefined()
})
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
const f = fixture({ session: durable("b", "low") })
const { local } = f.mount()
local.agent.set("plan")
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
f.set("sessions", "ses_a", durable("b", "low", "plan"))
f.emit("session.agent.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
f.set("sessions", "ses_a", durable("a", "high", "plan"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.model.set(key(model))
local.model.variant.set("low")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
})
test("a delayed commit preserves a newer agent even when model and variant match", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.agent.set("plan")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
})
test("a commit received while its session is inactive does not discard its local selection", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
f.set("sessions", "ses_b", durable("b", "low"))
f.set("route", "id", "ses_b")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("sessions", "ses_a", durable("c", "low"))
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
})
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
cancel()
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low"))
expect(local.model.current()?.id).toBe("a")
local.model.set(key("b"))
local.model.variant.set("low")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
cancel()
f.set("sessions", "ses_a", durable("b", "low"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "high"))
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
})
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
const f = fixture({ session: durable("a") })
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
const { local } = f.mount()
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("LocalProvider selection integration", async () => {
// Isolate provider module substitutions from the rest of the browser suite.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-selection.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
@@ -48,40 +48,6 @@ function desktop() {
}
describe("schema-backed persistence", () => {
for (const mode of ["web", "desktop"] as const) {
test(`relocates an extracted workspace key from an alias in ${mode} storage`, async () => {
const native = desktop()
const target = {
...Persist.workspace(`C:\\extension-relocation-${mode}`, "extension.example.document"),
previousKeys: ["workspace:legacy"],
}
const previous = `${target.workspaceStorageAliases![0]}:workspace:legacy`
const current = `${target.storage}:${target.key}`
const read = (key: string) => (mode === "web" ? localStorage.getItem(key) : native.values.get(key))
const raw = JSON.stringify({ enabled: false, label: "retained" })
if (mode === "web") localStorage.setItem(previous, raw)
if (mode === "desktop") native.values.set(previous, raw)
const mounted = createRoot((dispose) => {
const state = persisted(target, Current, initial, mode === "web" ? web : native.platform)
const ready = new Promise<void>((resolve) =>
createComputed(() => {
if (state[3]()) resolve()
}),
)
return { dispose, state, ready }
})
try {
await mounted.ready
expect(mounted.state[3]()).toBe(true)
expect(mounted.state[0]).toEqual({ enabled: false, label: "retained" })
expect(read(current)).toBe(raw)
expect(read(previous)).toBeFalsy()
} finally {
mounted.dispose()
}
})
}
test("clears the recent tab after restoring it from storage", () => {
const target = Persist.global("schema-recent-clear")
const key = `${target.storage}:${target.key}`
+25
View File
@@ -0,0 +1,25 @@
# Vite TUI entrypoint
From the repository root:
```sh
bun run dev:vite:live /path/to/project
```
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
- Component edits hot-update through the existing Solid refresh runtime.
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
- Correcting syntax errors retries a failed reload. The backend stays alive.
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
- Launcher/config/dependency changes require restarting the development client.
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation.
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
import { host } from "@opencode/cli/vite-host"
import { configureErrorOverlay } from "./refresh"
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
import.meta.hot.on("vite:beforeFullReload", async () => {
await host.stop?.()
host.reset?.()
})
}
const { run } = await import("../../tui/src/index")
// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads.
const { ErrorOverlay } = await import("./error-overlay")
configureErrorOverlay(ErrorOverlay)
await host.mount?.(run)
+56
View File
@@ -0,0 +1,56 @@
/* @refresh skip */
import { BoxRenderable, TextAttributes } from "@opentui/core"
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../../tui/src/context/theme"
import { Dialog } from "../../tui/src/ui/dialog"
import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
mode: "modal",
priority: 1000,
commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }],
}))
onMount(() => focus?.blur())
onCleanup(() => {
if (focus && !focus.isDestroyed) focus.focus()
})
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Anchor Portal's wrapper above the app rather than after it in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 5000
}}
>
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
</Dialog>
</Portal>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { Effect, Fiber, FileSystem } from "effect"
import type { TuiInput } from "@opencode/tui"
import type { Global } from "@opencode/util/global"
import type { Route } from "../../tui/src/context/route"
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
export declare const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
recover?: () => boolean
settle?: () => void
route?: Route
}
+2
View File
@@ -0,0 +1,2 @@
// External to Vite's module cache: keep lifecycle and route state across reloads.
export const host = {}
+3
View File
@@ -0,0 +1,3 @@
declare module "solid-refresh/dist/solid-refresh.mjs" {
export * from "solid-refresh"
}
+66
View File
@@ -0,0 +1,66 @@
/// <reference types="vite/client" />
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
import type { ErrorOverlay } from "./error-overlay"
import { host } from "@opencode/cli/vite-host"
let overlay: typeof ErrorOverlay
const [activeError, setActiveError] = createSignal<symbol>()
export function configureErrorOverlay(component: typeof ErrorOverlay) {
overlay = component
}
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
export { refresh as $$refresh }
export { component as $$component }
function refresh(...args: Parameters<typeof $$refresh>) {
// The native runner can re-evaluate a dependency in a cycle without accepting
// an update for that module. Preserve context identity now, before an updated
// consumer renders, rather than waiting for solid-refresh's accept callback.
const previous = args[1].data?.["solid-refresh"]
args[2].contexts.forEach((entry, id) => {
const old = previous?.contexts.get(id)
if (!old) return
old.context.defaultValue = entry.context.defaultValue
entry.context.id = old.context.id
entry.context.Provider = old.context.Provider
})
$$refresh(...args)
}
function component<P extends Record<string, unknown>>(
registry: Registry,
id: string,
render: (props: P) => JSX.Element,
options?: Parameters<typeof $$component>[3],
) {
const proxy = $$component(registry, id, render, options)
return (props: P) =>
createComponent(ErrorBoundary, {
fallback(error: unknown, reset: () => void) {
if (host.recover?.()) return null
const token = Symbol(id)
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
setActiveError(token)
onCleanup(() => {
if (activeError() === token) setActiveError(undefined)
})
// Retry only this failed subtree. Resetting the app's boundary destroys its providers and route.
import.meta.hot?.on("vite:afterUpdate", reset)
onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset))
return createComponent(Show, {
keyed: true,
get when() {
return activeError() === token
},
get children() {
return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) })
},
})
},
get children() {
return createComponent(proxy, props)
},
})
}
+43
View File
@@ -0,0 +1,43 @@
import { createEffect, on, type ComponentProps } from "solid-js"
import { unwrap } from "solid-js/store"
import { host } from "@opencode/cli/vite-host"
import { RouteProvider, useRoute } from "../../tui/src/context/route"
export {
useRoute,
useRouteData,
type Route,
type HomeRoute,
type SessionRoute,
type PluginRoute,
} from "../../tui/src/context/route"
export { ReloadableRouteProvider as RouteProvider }
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
return (
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
<RememberRoute />
{props.children}
</RouteProvider>
)
}
function RememberRoute() {
const route = useRoute()
createEffect(
on(
() => JSON.stringify(route.data),
() => {
// A route's prompt is a one-shot handoff, not a composer draft.
const value = structuredClone(unwrap({ ...route.data }))
host.route =
value.type === "home"
? { type: "home", location: value.location }
: value.type === "session"
? { type: "session", sessionID: value.sessionID }
: value
},
),
)
return null
}
+188
View File
@@ -0,0 +1,188 @@
import { createRequire } from "node:module"
import path from "node:path"
import { Effect, Exit, Fiber } from "effect"
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
import solid from "vite-plugin-solid"
import refresh from "solid-refresh/babel"
import { host, type Run } from "./host.js"
const require = createRequire(import.meta.url)
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
let initial = true
let recoverable = false
host.route = undefined
host.settle = () => {
recoverable = false
}
host.stop = async () => {
const fiber = host.active
host.active = undefined
if (fiber) await Effect.runPromise(Fiber.interrupt(fiber))
}
host.mount = async (app) => {
await host.stop?.()
const fiber = fork(
app({
...input,
args: initial
? input.args
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
terminalHandoff: initial ? input.terminalHandoff : undefined,
}),
)
initial = false
host.active = fiber
fiber.addObserver((exit) => {
if (host.active !== fiber) return
host.active = undefined
finished.resolve(exit)
})
}
const server = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createServer({
root: path.resolve(import.meta.dirname, "../../tui"),
configFile: false,
appType: "custom",
clearScreen: false,
logLevel: "error",
server: { middlewareMode: true, ws: false },
resolve: {
alias: [
{ find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") },
{
find: /^solid-js\/store(?:\/dist\/store.js)?$/,
replacement: require.resolve("solid-js/store/dist/dev.js"),
},
],
},
plugins: [
{
name: "tui-refresh-boundaries",
enforce: "pre",
async resolveId(source, importer) {
if (importer === path.join(import.meta.dirname, "route.tsx")) return
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
const resolved = await this.resolve(source, importer, { skipSelf: true })
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
return path.join(import.meta.dirname, "route.tsx")
},
load(id) {
if (id === "/@solid-refresh")
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
},
},
solid({
hot: false,
dev: true,
solid: { generate: "universal", moduleName: "@opentui/solid" },
// Enable the existing refresh plugin in Vite's non-browser environment.
babel: { plugins: [[refresh, { bundler: "vite" }]] },
}),
{
name: "tui-recovery",
hotUpdate() {
if (this.environment.name !== "native") return
recoverable = Boolean(host.active)
if (host.active) return
this.environment.moduleGraph.invalidateAll()
this.environment.hot.send({ type: "full-reload" })
return []
},
},
],
environments: {
native: {
consumer: "server",
resolve: {
conditions: ["bun", "development", "module"],
externalConditions: ["bun", "node"],
noExternal: [
"solid-js",
"solid-refresh",
"@opentui/solid",
"@opentui/keymap",
"opentui-spinner",
/^@solid-primitives\//,
"@opencode/plugin",
"@opencode/client",
"@opencode/latex",
"@opencode/merman",
],
// Exact subpaths are needed for workspace TypeScript exports.
external: [
"@opentui/core",
"@opentui/core/testing",
"effect",
"@opencode/cli/vite-host",
"@opencode/client",
"@opencode/client/effect/service",
"@opencode/client/promise",
"@opencode/core/util/slug",
"@opencode/schema",
"@opencode/schema/event",
"@opencode/schema/project",
"@opencode/schema/session-id",
"@opencode/schema/session-inbox",
"@opencode/schema/session-message",
"@opencode/schema/skill",
"@opencode/schema/token-usage",
"@opencode/schema/vcs",
"@opencode/schema/worktree",
"@opencode/simulation/frontend",
"@opencode/simulation/protocol",
"@opencode/theme/tui",
"@opencode/theme/tui/v1",
"@opencode/util/activity-calendar",
"@opencode/util/flock",
"@opencode/util/global",
"@opencode/util/hash",
"@opencode/util/session-title-fallback",
],
},
optimizeDeps: { noDiscovery: true, include: [] },
dev: {
createEnvironment: (name, config) =>
createRunnableDevEnvironment(name, config, {
runnerOptions: {
sourcemapInterceptor: false,
hmr: { logger: { debug() {}, error: (error) => console.error(error) } },
},
}),
},
},
},
}),
),
(server) =>
Effect.promise(async () => {
await host.stop?.()
await server.close()
}),
)
const environment = server.environments.native
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
host.reset = () => {
recoverable = false
environment.runner.clearCache()
}
host.recover = () => {
if (!recoverable) return false
recoverable = false
queueMicrotask(() => {
input.log?.("warn", "TUI hot update failed; reloading", {})
environment.moduleGraph.invalidateAll()
environment.hot.send({ type: "full-reload" })
})
return true
}
yield* Effect.promise(() =>
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
)
const exit = yield* Effect.promise(() => finished.promise)
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
}, Effect.scoped)
+15
View File
@@ -0,0 +1,15 @@
import { plugin } from "bun"
import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin"
ensureSolidTransformPlugin()
if (process.argv[2] !== "serve") {
// Vite must initialize before the CLI installs its process/error handling on Bun.
const { run } = await import("./tui")
plugin({
name: "vite-tui-entry",
setup(build) {
build.module("@opencode/tui", () => ({ loader: "object", exports: { run } }))
},
})
}
await import("../src/index")
+2
View File
@@ -11,6 +11,7 @@
"bin"
],
"exports": {
"./vite-host": "./dev/host.js",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
@@ -74,6 +75,7 @@
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
}
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from "bun:test"
import { createComponent, createContext, createRoot, useContext } from "solid-js"
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
import { $$refresh } from "../dev/refresh"
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
const previous = $$registry()
const mounted = $$context(previous, "Context", createContext("old default"))
const next = $$registry()
const updated = $$context(next, "Context", createContext("new default"))
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
let accepted = false
$$refresh(
"vite",
{
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
accept() {
accepted = true
},
invalidate() {
throw new Error("Unexpected invalidation")
},
decline() {
throw new Error("Unexpected decline")
},
},
next,
)
// Only register acceptance: Vite re-evaluates cyclic dependencies without
// necessarily sending those modules their own accepted update.
expect(accepted).toBe(true)
createRoot((dispose) => {
createComponent(mounted.Provider, {
value: "mounted provider",
get children() {
expect(useContext(updated)).toBe("mounted provider")
expect(useContext(unrelated)).toBe("unrelated")
return undefined
},
})
expect(useContext(mounted)).toBe("new default")
dispose()
})
})
+65
View File
@@ -0,0 +1,65 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { RouteProvider, useRoute, type Route } from "../dev/route"
import { host } from "../dev/host.js"
import { TuiStartupProvider } from "../../tui/src/context/runtime"
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
const saved = () => host.route
let route!: ReturnType<typeof useRoute>
function Probe() {
route = useRoute()
return null
}
async function render() {
return testRender(
() => (
<TuiStartupProvider value={{ skipInitialLoading: true }}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
<Probe />
</RouteProvider>
</TuiStartupProvider>
),
{ width: 80, height: 24 },
)
}
const routes: Route[] = [
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
{ type: "session", sessionID: "ses_selected" },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
]
host.route = undefined
const app = await render()
try {
await app.waitFor(() => host.route !== undefined)
for (const value of routes) {
route.navigate(
value.type === "plugin"
? value
: {
...value,
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
},
)
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
expect(saved()).toEqual(value)
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
expect(structuredClone(saved())).toEqual(value)
}
} finally {
app.renderer.destroy()
}
for (const value of routes) {
host.route = value
const restored = await render()
try {
expect(route.data).toEqual(value)
} finally {
restored.renderer.destroy()
}
}
host.route = undefined
})
+1
View File
@@ -518,6 +518,7 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly boundary: Session.ForkBoundary
readonly messages?: ReadonlyArray<SessionMessage.InfoEncoded> | undefined
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
+235 -84
View File
@@ -159,6 +159,76 @@ export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionMessageAgentSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type SessionMessageSynthetic1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
description?: string
type: "synthetic"
}
export type SessionMessageSystem1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "system"
text: string
description?: string
}
export type SessionMessageSkill1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "skill"
skill: string
name: string
text: string
}
export type SessionMessageShell1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; completed?: number }
type: "shell"
shellID: string
command: string
status: "running" | "exited" | "timeout" | "killed"
exit?: number
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageCompactionRunning1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "running"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
@@ -174,16 +244,6 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
@@ -454,6 +514,17 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMessageLocationSwitched1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "location-switched"
location: LocationRef
projectID?: string
subpath?: string
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type V2EventRpc = {
id: string
created: number
@@ -489,6 +560,15 @@ export type SessionMessageModelSelected = {
previous?: ModelRef
}
export type SessionMessageModelSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "model-switched"
model: ModelRef
previous?: ModelRef
}
export type PromptFileAttachment = {
data: PromptBase64
mime: string
@@ -525,6 +605,16 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionMessageCompactionFailed1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "failed"
reason: "auto" | "manual"
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
@@ -1200,37 +1290,13 @@ export type McpResourcesChanged = {
data: { server: string }
}
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type SessionStepEnded = {
@@ -1333,16 +1399,40 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
@@ -1703,6 +1793,17 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageUser1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
type: "user"
}
export type SessionInboxUserPayload1 = {
text: string
files?: Array<PromptFileAttachment>
@@ -1740,6 +1841,20 @@ export type SessionMessageCompactionCompleted = {
providerContext?: SessionProviderContext
}
export type SessionMessageCompactionCompleted1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
@@ -1758,20 +1873,19 @@ export type SessionCompactionEnded = {
}
}
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionToolSuccess = {
@@ -1811,21 +1925,6 @@ export type SessionToolFailed = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type ModelInfo = {
id: string
modelID: string
@@ -2104,6 +2203,11 @@ export type SessionMessageCompaction =
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompaction1 =
| SessionMessageCompactionRunning1
| SessionMessageCompactionCompleted1
| SessionMessageCompactionFailed1
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -2153,6 +2257,24 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistant1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; streamed?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
content: Array<SessionMessageAssistantText1 | SessionMessageAssistantReasoning1 | SessionMessageAssistantTool1>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
@@ -2178,6 +2300,18 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionMessageInfoEncoded =
| SessionMessageAgentSelected1
| SessionMessageModelSelected1
| SessionMessageLocationSwitched1
| SessionMessageUser1
| SessionMessageSynthetic1
| SessionMessageSystem1
| SessionMessageSkill1
| SessionMessageShell1
| SessionMessageAssistant1
| SessionMessageCompaction1
export type SessionMessageContentUpdated = {
id: string
created: number
@@ -2210,6 +2344,31 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
messages?: Array<SessionMessageInfoEncoded>
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
@@ -2255,14 +2414,6 @@ export type SessionEventDurable =
| SessionMessageContentUpdated
| SessionUsageRecorded
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
+6
View File
@@ -91,6 +91,12 @@ runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
### `Values`
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values

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