mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45923461e | ||
|
|
9494aeec92 | ||
|
|
aeacdceaea | ||
|
|
11aee1b49f | ||
|
|
a322d7f98f | ||
|
|
546b9fb3c1 | ||
|
|
83288921f6 | ||
|
|
510b771946 | ||
|
|
78915a0f17 | ||
|
|
1ead17547b | ||
|
|
0c1bf08ca6 | ||
|
|
8b09f6415a | ||
|
|
e791afdfa3 | ||
|
|
e655fed6c3 | ||
|
|
ccbc018072 | ||
|
|
4432956490 | ||
|
|
375bf4908f | ||
|
|
cc6bff39a0 | ||
|
|
8a5709324f | ||
|
|
be58ca806c | ||
|
|
9e42e5cc4c | ||
|
|
c2a1649dd4 | ||
|
|
ded9c7e505 | ||
|
|
f9bc2233dd | ||
|
|
7487999e06 | ||
|
|
2eea36e731 | ||
|
|
4fef8edbe8 | ||
|
|
50c552f763 | ||
|
|
a3d5923aca | ||
|
|
ea2c0184ce |
@@ -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 }}
|
||||
|
||||
@@ -63,9 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-context-desktop": "workspace:*",
|
||||
"@opencode/plugin-review-desktop": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
@@ -170,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:",
|
||||
},
|
||||
@@ -428,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:",
|
||||
@@ -601,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:*",
|
||||
@@ -636,66 +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/plugin-context-desktop": {
|
||||
"name": "@opencode/plugin-context-desktop",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
"luxon": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin-review-desktop": {
|
||||
"name": "@opencode/plugin-review-desktop",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
"@solid-primitives/event-listener": "catalog:",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode/posts",
|
||||
"dependencies": {
|
||||
@@ -1105,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",
|
||||
@@ -2283,12 +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/plugin-context-desktop": ["@opencode/plugin-context-desktop@workspace:packages/plugin-context-desktop"],
|
||||
|
||||
"@opencode/plugin-review-desktop": ["@opencode/plugin-review-desktop@workspace:packages/plugin-review-desktop"],
|
||||
|
||||
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
|
||||
@@ -2319,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"],
|
||||
|
||||
@@ -6493,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=="],
|
||||
|
||||
@@ -7391,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=="],
|
||||
|
||||
@@ -8327,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=="],
|
||||
|
||||
@@ -8995,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=="],
|
||||
|
||||
@@ -9227,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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,43 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("context opens independently, exports its session, and restores the closed panel", async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
const toggle = page.getByRole("button", { name: "View context usage", exact: true })
|
||||
await toggle.click()
|
||||
const context = page.getByRole("tabpanel", { name: "Context", exact: true })
|
||||
await expect(context.getByText("Total Tokens", { exact: true })).toBeVisible()
|
||||
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
|
||||
await expect(context.getByText("Raw messages", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath("context-extension.png") })
|
||||
const pending = page.waitForEvent("download")
|
||||
await context.getByRole("button", { name: "Export session", exact: true }).click()
|
||||
const download = await pending
|
||||
const exported: { info: { id: string }; messages: { id: string }[] } = JSON.parse(
|
||||
await readFile((await download.path())!, "utf8"),
|
||||
)
|
||||
expect(exported.info.id).toBe(fixture.sourceID)
|
||||
expect(exported.messages.length).toBeGreaterThan(0)
|
||||
expect(new Set(exported.messages.map((message) => message.id)).size).toBe(exported.messages.length)
|
||||
await toggle.click()
|
||||
await expect(page.getByRole("tab", { name: "Context", exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
)
|
||||
await toggle.click()
|
||||
await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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")!,
|
||||
)
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -59,9 +59,6 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
})
|
||||
test.afterEach(async ({ page }, info) => {
|
||||
if (info.status === "passed") await page.screenshot({ path: info.outputPath("review-files-extension.png") })
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: 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 },
|
||||
|
||||
@@ -139,7 +139,7 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expect(page.locator("#review-panel").getByRole("tab", { name: "Files Changed 2740", exact: true })).toBeVisible()
|
||||
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -64,10 +64,8 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-review-desktop": "workspace:*",
|
||||
"@opencode/plugin-context-desktop": "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`,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const extensionTabKey = (plugin: string, id: string) => `extension:${plugin}:${id}`
|
||||
export const isExtensionTab = (id: string | undefined) => !!id?.startsWith("extension:")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -1,550 +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 { ReviewDesktop } from "@opencode/plugin-review-desktop"
|
||||
import { ContextDesktop } from "@opencode/plugin-context-desktop"
|
||||
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 = () => [ReviewDesktop, ContextDesktop, ...(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),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1 +1,24 @@
|
||||
export { fileManagerApp, type FileManagerOS } from "@opencode/util/file-manager"
|
||||
export type FileManagerOS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
export function fileManagerApp(os: FileManagerOS): {
|
||||
label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager"
|
||||
actionLabel:
|
||||
| "session.header.reveal.finder"
|
||||
| "session.header.reveal.fileExplorer"
|
||||
| "session.header.reveal.containingFolder"
|
||||
icon: "finder" | "file-explorer"
|
||||
} {
|
||||
if (os === "macos")
|
||||
return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" }
|
||||
if (os === "windows")
|
||||
return {
|
||||
label: "session.header.open.fileExplorer",
|
||||
actionLabel: "session.header.reveal.fileExplorer",
|
||||
icon: "file-explorer",
|
||||
}
|
||||
return {
|
||||
label: "session.header.open.fileManager",
|
||||
actionLabel: "session.header.reveal.containingFolder",
|
||||
icon: "finder",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
@import "@opencode/session-ui/styles";
|
||||
@import "@opencode/ui/styles/tokens";
|
||||
@import "tw-animate-css";
|
||||
@source "../../plugin-review-desktop/src";
|
||||
@source "../../plugin-context-desktop/src";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
@@ -72,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"],
|
||||
@@ -141,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 })
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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">>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,68 @@
|
||||
export {
|
||||
fetchSessionExport,
|
||||
saveSessionExport,
|
||||
sessionExportFilename,
|
||||
downloadSessionExport,
|
||||
type SessionExportData,
|
||||
} from "@opencode/plugin-context-desktop/export"
|
||||
import type { SessionInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
|
||||
export type SessionExportData = {
|
||||
info: SessionInfo
|
||||
messages: SessionMessageInfo[]
|
||||
}
|
||||
|
||||
export async function fetchSessionExport(input: {
|
||||
sessionID: string
|
||||
api: Pick<ServerApi, "session" | "message">
|
||||
}): Promise<SessionExportData> {
|
||||
const [info, first] = await Promise.all([
|
||||
input.api.session.get({ sessionID: input.sessionID }),
|
||||
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }),
|
||||
])
|
||||
const pages = [first]
|
||||
|
||||
while (pages.at(-1)?.cursor.next) {
|
||||
pages.push(
|
||||
await input.api.message.list({
|
||||
sessionID: input.sessionID,
|
||||
limit: 200,
|
||||
cursor: pages.at(-1)!.cursor.next ?? undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
info,
|
||||
messages: pages.flatMap((page) => page.data),
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
|
||||
const name = session.title || session.slug || session.id
|
||||
const clean = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return `${clean || session.id}.json`
|
||||
}
|
||||
|
||||
export function downloadSessionExport(filename: string, data: unknown) {
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function saveSessionExport(
|
||||
filename: string,
|
||||
data: unknown,
|
||||
platform: Pick<Platform, "saveFile">,
|
||||
) {
|
||||
if (!platform.saveFile) {
|
||||
downloadSessionExport(filename, data)
|
||||
return true
|
||||
}
|
||||
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
@@ -160,6 +160,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void openDialog(
|
||||
() => import("@/shell/commands/dialog"),
|
||||
(x) => dialog.show(() => <x.DialogCommandPalette onOpenFile={showAllFiles} />),
|
||||
)
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
const tab = actions.session.tabs.closableTab()
|
||||
if (!tab) return
|
||||
@@ -304,6 +311,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const fileCmds = () => {
|
||||
const tab = actions.session.tabs.closableTab()
|
||||
return [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: openFile,
|
||||
}),
|
||||
tab &&
|
||||
fileCommand({
|
||||
id: "file.close",
|
||||
|
||||
@@ -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(
|
||||
|
||||
+18
-19
@@ -11,18 +11,14 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { usePlugin } from "@opencode/plugin/desktop"
|
||||
import type { SessionView } from "@opencode/plugin/desktop/workspace"
|
||||
import {
|
||||
selectionFromLines,
|
||||
useFile,
|
||||
useComments,
|
||||
useLanguage,
|
||||
useComposerState,
|
||||
useEnvironment,
|
||||
type FileSelection,
|
||||
type SelectedLineRange,
|
||||
} from "../environment"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { getSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
|
||||
type SessionFileViewProps = {
|
||||
tab: string
|
||||
@@ -56,7 +52,7 @@ function FileCommentMenu(props: {
|
||||
|
||||
type ScrollPos = { x: number; y: number }
|
||||
|
||||
function createScrollSync(input: { tab: () => string; view: () => SessionView }) {
|
||||
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
@@ -186,10 +182,12 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
const language = useLanguage()
|
||||
const prompt = useComposerState()
|
||||
const fileComponent = useFileComponent()
|
||||
const environment = useEnvironment()
|
||||
const view = () => environment.services.view
|
||||
const activeFileTab = view().tabs.active
|
||||
const toast = usePlugin().ui.toast
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
let find: FileSearchHandle | null = null
|
||||
|
||||
@@ -210,7 +208,8 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
return file.selectedLines(p) ?? null
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
const scrollSync = createScrollSync({
|
||||
tab: () => props.tab,
|
||||
@@ -441,7 +440,7 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
toast.show({
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
+2
-7
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
buildFileTreeV2Model,
|
||||
flattenFileTreeV2,
|
||||
flattenLiveFileTreeV2,
|
||||
sortFileTreeV2Paths,
|
||||
} from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@opencode/plugin/desktop/workspace"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@/runtime/server/types"
|
||||
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
test("builds a sorted tree and flattens expanded directories", () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { FileNode } from "@opencode/plugin/desktop/workspace"
|
||||
import type { FileNode } from "@/runtime/server/types"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
+13
-12
@@ -1,6 +1,5 @@
|
||||
import { useFile, useWorkspaceLocation, usePlatform } from "../environment"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { FileTreeItem } from "@opencode/ui/file-tree-item"
|
||||
import "@opencode/ui/file-tree.css"
|
||||
import {
|
||||
createEffect,
|
||||
@@ -13,9 +12,9 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode/plugin/desktop/workspace"
|
||||
import type { FileNode } from "@/runtime/server/types"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "./file-tree"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/session/files/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import {
|
||||
buildFileTreeV2Model,
|
||||
@@ -23,13 +22,15 @@ import {
|
||||
flattenLiveFileTreeV2,
|
||||
normalizeFileTreeV2Path,
|
||||
type FileTreeV2Node,
|
||||
} from "./file-tree-v2-model"
|
||||
import { createVirtualScrollElement } from "./virtual-scroll"
|
||||
import { useOpenInApp } from "./open-in-app"
|
||||
import { OpenInAppContextMenuV2 } from "./open-in-app-button"
|
||||
import { resolveOpenInAppPath } from "./open-in-app-path"
|
||||
} from "@/session/files/file-tree-v2-model"
|
||||
import { virtualScrollElement } from "@/session/files/virtual-scroll"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useOpenInApp } from "@/session/files/open-in-app"
|
||||
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
|
||||
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export type { Kind } from "./file-tree"
|
||||
export type { Kind } from "@/session/files/file-tree"
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
@@ -82,7 +83,7 @@ const FileTreeNodeV2 = (
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as === "button" ? FileTreeItem : "div"}
|
||||
component={local.as ?? "div"}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-path={local.node.path}
|
||||
data-selected={local.node.path === local.active ? "" : undefined}
|
||||
@@ -158,7 +159,7 @@ export default function FileTreeV2(props: {
|
||||
get count() {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: createVirtualScrollElement(root),
|
||||
getScrollElement: () => virtualScrollElement(root()),
|
||||
initialRect: { width: 0, height: 600 },
|
||||
estimateSize: () => 28,
|
||||
gap: 2,
|
||||
+38
-2
@@ -1,5 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldListRoot, shouldListExpanded, dirsToExpand } from "./file-tree"
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let shouldListRoot: typeof import("./file-tree").shouldListRoot
|
||||
let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
|
||||
let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@/workspaces/files/model", () => ({
|
||||
useFile: () => ({
|
||||
tree: {
|
||||
state: () => undefined,
|
||||
list: () => Promise.resolve(),
|
||||
children: () => [],
|
||||
expand: () => {},
|
||||
collapse: () => {},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
mock.module("@opencode/ui/collapsible", () => ({
|
||||
Collapsible: {
|
||||
Trigger: (props: { children?: unknown }) => props.children,
|
||||
Content: (props: { children?: unknown }) => props.children,
|
||||
},
|
||||
}))
|
||||
mock.module("@opencode/ui/file-icon", () => ({ FileIcon: () => null }))
|
||||
mock.module("@opencode/ui/icon", () => ({ Icon: () => null }))
|
||||
mock.module("@opencode/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
|
||||
const mod = await import("./file-tree")
|
||||
shouldListRoot = mod.shouldListRoot
|
||||
shouldListExpanded = mod.shouldListExpanded
|
||||
dirsToExpand = mod.dirsToExpand
|
||||
})
|
||||
|
||||
describe("file tree fetch discipline", () => {
|
||||
test("root lists on mount unless already loaded or loading", () => {
|
||||
+4
-8
@@ -1,4 +1,5 @@
|
||||
import { useFile } from "../environment"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import { Collapsible } from "@opencode/ui/collapsible"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
@@ -16,17 +17,12 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode/plugin/desktop/workspace"
|
||||
import type { FileNode } from "@/runtime/server/types"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
export function pathToFileUrl(filepath: string): string {
|
||||
const normalized = filepath.replaceAll("\\", "/")
|
||||
const absolute = /^[A-Za-z]:/.test(normalized) ? `/${normalized}` : normalized
|
||||
return `file://${absolute
|
||||
.split("/")
|
||||
.map((segment, index) => (index === 1 && /^[A-Za-z]:$/.test(segment) ? segment : encodeURIComponent(segment)))
|
||||
.join("/")}`
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
export type Kind = "add" | "del" | "mix"
|
||||
+14
-23
@@ -1,16 +1,16 @@
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { FileTreeItem } from "@opencode/ui/file-tree-item"
|
||||
import "@opencode/ui/file-tree.css"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "./file-tree-v2"
|
||||
import { normalizePath } from "../review/review-diff-kinds"
|
||||
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
|
||||
import { normalizePath } from "@/session/review/review-diff-kinds"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { createVirtualScrollElement } from "./virtual-scroll"
|
||||
import { useWorkspaceLocation, usePlatform } from "../environment"
|
||||
import { useOpenInApp } from "./open-in-app"
|
||||
import { OpenInAppContextMenuV2 } from "./open-in-app-button"
|
||||
import { resolveOpenInAppPath } from "./open-in-app-path"
|
||||
import { virtualScrollElement } from "@/session/files/virtual-scroll"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useOpenInApp } from "@/session/files/open-in-app"
|
||||
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
|
||||
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
// Drives the highlight/selection of the flat search-result list from the filter
|
||||
// input's keyboard events.
|
||||
@@ -43,7 +43,7 @@ export function applyFileListKeyDown(
|
||||
// row data-slots on purpose so file-tree-v2.css styles both. data-highlighted has
|
||||
// no CSS of its own — it folds into data-selected below and only exists as the
|
||||
// scrollIntoView query hook.
|
||||
type FileListProps = {
|
||||
export function SessionFileList(props: {
|
||||
files: readonly string[]
|
||||
active?: string
|
||||
highlighted?: string
|
||||
@@ -53,16 +53,10 @@ type FileListProps = {
|
||||
optionID?: (path: string) => string
|
||||
onFileClick: (path: string) => void
|
||||
onFileDoubleClick?: (path: string) => void
|
||||
}
|
||||
|
||||
export function SessionFileList(props: FileListProps) {
|
||||
}) {
|
||||
const location = useWorkspaceLocation()
|
||||
const platform = usePlatform()
|
||||
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
|
||||
return <FileListView {...props} directory={location().directory} openIn={openIn} />
|
||||
}
|
||||
|
||||
export function FileListView(props: FileListProps & { directory?: string; openIn?: ReturnType<typeof useOpenInApp> }) {
|
||||
const active = () => normalizePath(props.active ?? "")
|
||||
const highlighted = () => normalizePath(props.highlighted ?? "")
|
||||
const normalized = createMemo(() => props.files.map(normalizePath))
|
||||
@@ -72,7 +66,7 @@ export function FileListView(props: FileListProps & { directory?: string; openIn
|
||||
get count() {
|
||||
return props.files.length
|
||||
},
|
||||
getScrollElement: createVirtualScrollElement(root),
|
||||
getScrollElement: () => virtualScrollElement(root()),
|
||||
initialRect: { width: 0, height: 600 },
|
||||
estimateSize: () => 28,
|
||||
gap: 2,
|
||||
@@ -148,11 +142,8 @@ export function FileListView(props: FileListProps & { directory?: string; openIn
|
||||
transform: `translateY(${item().start}px)`,
|
||||
}}
|
||||
>
|
||||
<OpenInAppContextMenuV2
|
||||
state={props.openIn}
|
||||
path={() => resolveOpenInAppPath(props.directory ?? "", path)}
|
||||
>
|
||||
<FileTreeItem
|
||||
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
|
||||
<button
|
||||
type="button"
|
||||
id={props.optionID?.(path)}
|
||||
role={props.role ? "option" : undefined}
|
||||
@@ -184,7 +175,7 @@ export function FileListView(props: FileListProps & { directory?: string; openIn
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</FileTreeItem>
|
||||
</button>
|
||||
</OpenInAppContextMenuV2>
|
||||
</div>
|
||||
)}
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { Spinner } from "@opencode/ui/spinner"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "@opencode/ui/split-button"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useLanguage } from "../environment"
|
||||
import { type OpenApp, useOpenInApp } from "./open-in-app"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
|
||||
|
||||
export function OpenInAppButton(props: { directory: () => string }) {
|
||||
const language = useLanguage()
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
|
||||
|
||||
describe("open app preferences", () => {
|
||||
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
|
||||
expect(decode({ app })).toEqual({ app })
|
||||
})
|
||||
|
||||
test.each([undefined, null, 42, "unknown", {}])("defaults invalid selection %p", (app) => {
|
||||
expect(decode({ app })).toEqual({ app: "finder" })
|
||||
})
|
||||
|
||||
test("defaults an absent selection", () => {
|
||||
expect(decode({})).toEqual({ app: "finder" })
|
||||
})
|
||||
})
|
||||
+35
-29
@@ -1,9 +1,14 @@
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage, usePlatform, useEnvironment } from "../environment"
|
||||
import { usePlugin } from "@opencode/plugin/desktop"
|
||||
import { fileManagerApp } from "@opencode/util/file-manager"
|
||||
import { openInAppParentPath } from "./open-in-app-path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { openInAppParentPath } from "@/session/files/open-in-app-path"
|
||||
|
||||
export const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -23,11 +28,12 @@ export const OPEN_APPS = [
|
||||
] as const
|
||||
|
||||
export type OpenApp = (typeof OPEN_APPS)[number]
|
||||
export function openAppPreference(value: unknown): OpenApp {
|
||||
return OPEN_APPS.find((app) => app === value) ?? "finder"
|
||||
}
|
||||
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
export const OpenAppPreferences = Persistence.struct({
|
||||
app: Schema.Literals(OPEN_APPS),
|
||||
})
|
||||
|
||||
const appExistence = new Map<string, Promise<boolean>>()
|
||||
|
||||
export const MAC_OPEN_APPS = [
|
||||
@@ -115,17 +121,18 @@ export function openAppsForOS(os: OpenAppOS) {
|
||||
return LINUX_OPEN_APPS
|
||||
}
|
||||
|
||||
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpenInApp(input: { path: () => string }) {
|
||||
const platform = usePlatform()
|
||||
const environment = useEnvironment()
|
||||
const ctx = usePlugin()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const showRequestError = (err: unknown) =>
|
||||
ctx.ui.toast.show({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
|
||||
const os = createMemo(() => detectOpenAppOS(platform))
|
||||
const apps = createMemo(() => openAppsForOS(os()))
|
||||
@@ -159,18 +166,16 @@ export function useOpenInApp(input: { path: () => string }) {
|
||||
] as const
|
||||
})
|
||||
|
||||
const [prefs, setPrefs] = ctx.storage.store("open-app", { initial: { app: "finder" as OpenApp } })
|
||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), OpenAppPreferences, { app: "finder" })
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore({
|
||||
app: undefined as OpenApp | undefined,
|
||||
})
|
||||
|
||||
const canOpen = createMemo(
|
||||
() => platform.platform === "desktop" && !!platform.openPath && environment.session.server.local,
|
||||
)
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
|
||||
const current = createMemo(
|
||||
() =>
|
||||
options().find((o) => o.id === openAppPreference(prefs.app)) ??
|
||||
options().find((o) => o.id === prefs.app) ??
|
||||
options()[0] ??
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
@@ -178,9 +183,7 @@ export function useOpenInApp(input: { path: () => string }) {
|
||||
|
||||
const selectApp = (app: OpenApp | "finder") => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
setPrefs((draft) => {
|
||||
draft.app = app
|
||||
})
|
||||
setPrefs("app", app)
|
||||
}
|
||||
|
||||
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
|
||||
@@ -195,9 +198,11 @@ export function useOpenInApp(input: { path: () => string }) {
|
||||
app === "finder" && reveal && platform.revealPath
|
||||
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
|
||||
: open(target, openWith)
|
||||
request.catch(showRequestError).finally(() => {
|
||||
setOpenRequest("app", undefined)
|
||||
})
|
||||
request
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
.finally(() => {
|
||||
setOpenRequest("app", undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const copyPath = (target = input.path()) => {
|
||||
@@ -205,13 +210,14 @@ export function useOpenInApp(input: { path: () => string }) {
|
||||
navigator.clipboard
|
||||
.writeText(target)
|
||||
.then(() => {
|
||||
ctx.ui.toast.show({
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("common.copied"),
|
||||
message: target,
|
||||
description: target,
|
||||
})
|
||||
})
|
||||
.catch(showRequestError)
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
}
|
||||
|
||||
return {
|
||||
+61
-26
@@ -1,6 +1,8 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Accordion } from "@opencode/ui/accordion"
|
||||
@@ -9,10 +11,14 @@ import { File } from "@opencode/session-ui/file"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { usePlugin, type SessionContext } from "@opencode/plugin/desktop"
|
||||
import type { SessionView } from "@opencode/plugin/desktop/workspace"
|
||||
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "./export"
|
||||
import { contextUsage } from "./usage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
function Stat(props: { label: string; value: JSX.Element }) {
|
||||
@@ -76,14 +82,26 @@ function RawMessage(props: {
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
|
||||
export function SessionContextTab(props: { session: SessionContext; view: SessionView }) {
|
||||
const plugin = usePlugin()
|
||||
const data = props.session.server.data
|
||||
const language = plugin.i18n
|
||||
const platform = plugin.platform
|
||||
const view = () => props.view
|
||||
const info = createMemo(() => data.session.get(props.session.sessionID))
|
||||
const messages = createMemo(() => data.session.message.list(props.session.sessionID))
|
||||
export function SessionContextTab() {
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
() => {
|
||||
const id = params.id
|
||||
if (!id) return emptyMessages
|
||||
return data.session.message.list(id)
|
||||
},
|
||||
emptyMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -93,13 +111,28 @@ export function SessionContextTab(props: { session: SessionContext; view: Sessio
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() =>
|
||||
contextUsage(
|
||||
messages(),
|
||||
data.location.model.list(props.session.location) ?? [],
|
||||
data.location.provider.list(props.session.location) ?? [],
|
||||
),
|
||||
)
|
||||
const ctx = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const provider = providers.all().get(message.model.providerID)
|
||||
const model = provider?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
message,
|
||||
tokens: message.tokens,
|
||||
providerLabel: provider?.name ?? message.model.providerID,
|
||||
modelLabel: model?.name ?? message.model.id,
|
||||
limit: model?.limit.context,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -138,7 +171,7 @@ export function SessionContextTab(props: { session: SessionContext; view: Sessio
|
||||
})
|
||||
|
||||
const stats = [
|
||||
{ label: "context.stats.session", value: () => info()?.title ?? props.session.sessionID },
|
||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
@@ -160,24 +193,26 @@ export function SessionContextTab(props: { session: SessionContext; view: Sessio
|
||||
] satisfies { label: string; value: () => JSX.Element }[]
|
||||
|
||||
const exportSession = async () => {
|
||||
const sessionID = props.session.sessionID
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
api: props.session.server.client,
|
||||
api: serverSDK.api,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
if (!(await saveSessionExport(filename, data, platform))) return
|
||||
plugin.ui.toast.show({
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
message: language.t("toast.session.export.success.description", { filename }),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
plugin.ui.toast.show({
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
message: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
+26
-10
@@ -4,11 +4,17 @@ import { createQuery } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
|
||||
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
|
||||
import FileTreeV2, { type Kind } from "./file-tree-v2"
|
||||
import { useFile, useLanguage, useWorkspaceLocation, useServerSDK, useEnvironment } from "../environment"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { SessionFileView } from "./file-tabs"
|
||||
import { applyFileListKeyDown, SessionFileList } from "./list"
|
||||
import FileTreeV2, { type Kind } from "@/session/files/file-tree-v2"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { SessionFileView } from "@/session/files/file-tabs"
|
||||
import { applyFileListKeyDown, SessionFileList } from "@/session/files/list"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
const emptyFiles: string[] = []
|
||||
|
||||
@@ -34,8 +40,9 @@ export function SessionFileBrowserTab(props: {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const environment = useEnvironment()
|
||||
const server = useServer()
|
||||
const serverSDK = useServerSDK()
|
||||
const { workspaceKey } = useSessionLayout()
|
||||
const resultsID = `session-file-browser-results-${createUniqueId()}`
|
||||
const [store, setStore] = createStore({ filter: "", explicitHighlight: undefined as string | undefined })
|
||||
const filter = () => store.filter
|
||||
@@ -46,8 +53,8 @@ export function SessionFileBrowserTab(props: {
|
||||
const search = createQuery(() => {
|
||||
const value = query()
|
||||
return {
|
||||
queryKey: [serverSDK.scope, "session-open-file", sdk().directory, value] as const,
|
||||
enabled: environment.session.server.compatible && value.length > 0,
|
||||
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
|
||||
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
|
||||
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
|
||||
}
|
||||
})
|
||||
@@ -64,7 +71,16 @@ export function SessionFileBrowserTab(props: {
|
||||
})
|
||||
|
||||
const loading = createMemo(() => query().length > 0 && search.isPending)
|
||||
const title = createMemo(() => environment.services.project?.name ?? getFilename(sdk().directory))
|
||||
const project = createMemo(() => {
|
||||
const directory = pathKey(sdk().directory)
|
||||
return server.ctx.projects
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
)
|
||||
})
|
||||
const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory }))
|
||||
const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}`
|
||||
|
||||
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
|
||||
@@ -156,7 +172,7 @@ export function SessionFileBrowserTab(props: {
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-13-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
+2
@@ -18,10 +18,12 @@
|
||||
[data-component="session-review-v2-sidebar-root"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-sidebar"] {
|
||||
width: 100% !important;
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-preview"] {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { SessionFileBrowserTab } from "./session-file-browser-tab"
|
||||
import type { Kind } from "./file-tree-v2"
|
||||
import "./session-mobile-files.css"
|
||||
|
||||
export function SessionMobileFiles() {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const layout = useSessionLayout()
|
||||
const tabs = createSessionTabs({
|
||||
tabs: layout.tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: file.tab,
|
||||
})
|
||||
const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() })
|
||||
const browsing = () => store.browsing || !tabs.activeFileTab()
|
||||
const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? ""))
|
||||
const kinds = new Map<string, Kind>()
|
||||
const open = (path: string) => {
|
||||
layout.tabs().open(file.tab(path))
|
||||
void file.load(path)
|
||||
setStore("browsing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="session-mobile-files" data-browsing={browsing()} class="flex h-full min-h-0 flex-col">
|
||||
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="shrink-0 mx-2"
|
||||
onClick={() => setStore("browsing", true)}
|
||||
aria-pressed={browsing()}
|
||||
>
|
||||
{language.t("session.files.all")}
|
||||
</Button>
|
||||
<Tabs
|
||||
value={browsing() ? SESSION_OPEN_FILE_TAB : tabs.activeFileTab()}
|
||||
onChange={(tab) => {
|
||||
// Kobalte falls back to a file tab when the browse view has no trigger.
|
||||
if (browsing()) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) open(path)
|
||||
}}
|
||||
variant="line"
|
||||
class="min-w-0 flex-1 !h-auto"
|
||||
>
|
||||
<Tabs.List aria-label={language.t("session.files.openTabs")} class="!h-10 !px-0 overflow-x-auto">
|
||||
<For each={tabs.openedTabs()}>
|
||||
{(tab) => (
|
||||
<Tabs.Trigger
|
||||
value={tab}
|
||||
onClick={() => open(file.pathFromTab(tab)!)}
|
||||
class="shrink-0 max-w-48"
|
||||
classes={{ button: "min-w-0" }}
|
||||
closeButton={
|
||||
<Tabs.CloseButton
|
||||
aria-label={language.t("common.closeTab")}
|
||||
onClick={() => layout.tabs().close(tab)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span dir="ltr" class="truncate">
|
||||
{getFilename(file.pathFromTab(tab) ?? tab)}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionFileBrowserTab
|
||||
mobile
|
||||
tab={tabs.activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={browsing()}
|
||||
active={active()}
|
||||
kinds={kinds}
|
||||
state={{
|
||||
sidebarOpened: browsing,
|
||||
sidebarWidth: () => 240,
|
||||
sidebarTransition: () => false,
|
||||
resizeSidebar: () => undefined,
|
||||
toggleSidebar: () => setStore("browsing", !browsing()),
|
||||
}}
|
||||
onSelect={open}
|
||||
onSelectPermanent={open}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, onCleanup } from "solid-js"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable } from "@dnd-kit/solid/sortable"
|
||||
@@ -8,185 +8,694 @@ 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 { Menu } from "@opencode/ui/menu"
|
||||
import { Mark } from "@opencode/ui/logo"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { SortableTab } from "./tab"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
|
||||
import FileTree from "@/session/files/file-tree"
|
||||
import { normalizeFileTreeV2Path } from "@/session/files/file-tree-v2-model"
|
||||
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"
|
||||
import { OpenInAppButton } from "@/session/files/open-in-app-button"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "./file-tab-scroll"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
shouldShowFileTree,
|
||||
type Sizing,
|
||||
} from "@/session/helpers"
|
||||
import { setSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionTabs, type Sizing } from "@/session/helpers"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
import { ExtensionPanelContent } from "@/extensions/content"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
|
||||
import { SessionBrowserPane } from "@/session/browser/pane"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
const FILE_TREE_WIDTH_MIN = 240
|
||||
|
||||
function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
extensions: SessionExtensions
|
||||
present?: boolean
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
hasReview: boolean
|
||||
reviewHasFocusableContent: boolean
|
||||
reviewCount: number
|
||||
reviewPanel: () => JSX.Element
|
||||
reviewSidebarToggle: (disabled: boolean) => JSX.Element
|
||||
fileBrowserState: SessionFileBrowserState
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
mobile?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const file = useFile()
|
||||
const session = useSessionLayout()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
const projectDirectory = createMemo(() => sdk().directory)
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const extensions = props.extensions
|
||||
const opened = () => !!props.mobile || (isDesktop() && session.view().reviewPanel.opened())
|
||||
const visible = () => opened() || !!props.present
|
||||
const sidebar = () => !props.mobile && isDesktop() && settings.visibility.fileTree() && layout.fileTree.opened()
|
||||
const width = () => Math.max(240, layout.fileTree.width())
|
||||
const state = createSessionTabs({
|
||||
tabs: session.tabs,
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
|
||||
const fileOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
shouldShowFileTree({
|
||||
visible: shown(),
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const visible = createMemo(() => reviewVisible() || fileOpen())
|
||||
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!visible()) return "0px"
|
||||
if (reviewVisible()) return "auto"
|
||||
return `${fileTreeWidth()}px`
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
|
||||
const diffs = createMemo(() => props.diffs.filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
if (a === b) return a
|
||||
return "mix" as const
|
||||
}
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
const file = normalizeFileTreeV2Path(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
out.set(file, kind)
|
||||
|
||||
const parts = file.split("/")
|
||||
for (const [idx] of parts.slice(0, -1).entries()) {
|
||||
const dir = parts.slice(0, idx + 1).join("/")
|
||||
if (!dir) continue
|
||||
out.set(dir, merge(out.get(dir), kind))
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const empty = (msg: string) => (
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="h-6 shrink-0" aria-hidden />
|
||||
<div class="flex-1 pb-64 flex items-center justify-center text-center">
|
||||
<div class="text-12-regular text-text-weak">{msg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const nofiles = createMemo(() => {
|
||||
const state = file.tree.state("")
|
||||
if (!state?.loaded) return false
|
||||
return file.tree.children("").length === 0
|
||||
})
|
||||
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
}
|
||||
|
||||
const openReviewPanel = () => {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
}
|
||||
|
||||
const openTab = createOpenSessionFileTab({
|
||||
normalizeTab,
|
||||
openTab: tabs().open,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: file.tab,
|
||||
extensions: extensions.keys,
|
||||
defaultPanel: extensions.defaultPanel,
|
||||
canClose: extensions.canClose,
|
||||
loadFile: file.load,
|
||||
openReviewPanel,
|
||||
setActive: tabs().setActive,
|
||||
})
|
||||
const panelTabs = createMemo(() => {
|
||||
const keys = state.panelTabs()
|
||||
const preferred = extensions.defaultPanel()
|
||||
return preferred && !keys.includes(preferred) ? [preferred, ...keys] : keys
|
||||
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.attached,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
const panelTabs = tabState.panelTabs
|
||||
const openedTabs = tabState.openedTabs
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
|
||||
const setFileTreeTabValue = (value: string) => {
|
||||
if (value !== "changes" && value !== "all") return
|
||||
layout.fileTree.setTab(value)
|
||||
}
|
||||
|
||||
let fileFilter: HTMLInputElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
const temporaryTab = tabs().preview
|
||||
const previewTab = (value: string) => {
|
||||
const next = normalizeTab(value)
|
||||
tabs().previewTab(next)
|
||||
const path = file.pathFromTab(next)
|
||||
if (path) void file.load(path)
|
||||
openReviewPanel()
|
||||
queueMicrotask(() => tabs().setActive(next))
|
||||
}
|
||||
const openFileBrowser = () => {
|
||||
previewTab(SESSION_OPEN_FILE_TAB)
|
||||
queueMicrotask(() => fileFilter?.focus())
|
||||
}
|
||||
const activateTab = (value: string) => {
|
||||
const next = normalizeTab(value)
|
||||
const path = file.pathFromTab(next)
|
||||
if (path) void file.load(path)
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
const fileTab = createMemo(() => {
|
||||
const active = activeTab()
|
||||
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||
if (active && file.pathFromTab(active)) return active
|
||||
return activeFileTab()
|
||||
})
|
||||
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
|
||||
// selects Review while the tab For replaces a preview trigger, which would
|
||||
// otherwise dispose the sidebar and reset scroll.
|
||||
const fileBrowserMounted = createMemo(() => {
|
||||
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
setSessionHandoff(sessionKey(), {
|
||||
files: tabs()
|
||||
.all()
|
||||
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return acc
|
||||
|
||||
const selected = file.selectedLines(path)
|
||||
acc[path] =
|
||||
selected && typeof selected === "object" && "start" in selected && "end" in selected
|
||||
? (selected as SelectedLineRange)
|
||||
: null
|
||||
|
||||
return acc
|
||||
}, {}),
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={(isDesktop() || props.mobile) && session.params.id}>
|
||||
<Show when={isDesktop() && !!params.id}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
aria-hidden={!opened() && !sidebar()}
|
||||
inert={!opened() && !sidebar()}
|
||||
class="relative min-w-0 h-full flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
|
||||
classList={{
|
||||
"flex-1": visible(),
|
||||
"min-h-0": !!props.stacked,
|
||||
"shrink-0": !props.stacked,
|
||||
"pointer-events-none": !opened() && !sidebar(),
|
||||
"h-full shrink-0": !props.stacked,
|
||||
"h-full min-h-0": props.stacked,
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
"flex-1": reviewVisible(),
|
||||
}}
|
||||
style={{ width: visible() ? "auto" : sidebar() ? `${width()}px` : "0px" }}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={visible()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden">
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
event.target instanceof Element &&
|
||||
!!event.target.closest('[data-slot="tabs-trigger-close-button"]'),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
|
||||
session.tabs().move(source.id.toString(), source.index)
|
||||
}}
|
||||
>
|
||||
<Tabs value={state.activeTab()} onChange={(value) => session.tabs().setActive(value)}>
|
||||
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
|
||||
<Tabs.List
|
||||
ref={(element: HTMLDivElement) => {
|
||||
tabList = element
|
||||
onCleanup(createFileTabListSync({ el: element, contextOpen: state.contextOpen }))
|
||||
<div class="size-full flex">
|
||||
<Show when={reviewVisible()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
|
||||
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
event.target instanceof Element &&
|
||||
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
|
||||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
|
||||
}),
|
||||
]}
|
||||
modifiers={[
|
||||
RestrictToHorizontalAxis,
|
||||
RestrictToElement.configure({ element: () => tabList ?? null }),
|
||||
]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
|
||||
tabs().move(source.id.toString(), source.index)
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{extensions.toolbar()}
|
||||
</div>
|
||||
<For each={panelTabs()}>
|
||||
{(key) => (
|
||||
<Show when={extensions.panels().find((panel) => panel.key === key)}>
|
||||
{(panel) => (
|
||||
<SortableTab
|
||||
tab={key}
|
||||
index={session.tabs().all().indexOf(key)}
|
||||
temporary={panel().props.temporary}
|
||||
onTabClose={panel().props.closable === false ? undefined : session.tabs().close}
|
||||
onTabDoubleClick={panel().props.onDoubleClick}
|
||||
<Tabs value={activeTab()} onChange={activateTab}>
|
||||
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
tabList = el
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||
</div>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : 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}
|
||||
</span>
|
||||
<Show when={panel().props.badge}>{panel().props.badge}</Show>
|
||||
{props.hasReview
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount })
|
||||
: language.t("session.tab.review")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</SortableTab>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={extensions.hasActions()}>
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("session.tab.add")}
|
||||
onPointerDown={(event: PointerEvent) => event.preventDefault()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>{extensions.actions()}</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<For each={panelTabs()}>
|
||||
{(tab) => (
|
||||
<Switch
|
||||
fallback={
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
temporary={temporaryTab() === tab}
|
||||
onTabClose={tabs().close}
|
||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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={() => props.browser.close(item().id)}
|
||||
id={`${browserTabID}-${item().id}`}
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={tab === SESSION_OPEN_FILE_TAB}>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
class="group"
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100"
|
||||
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
tabs().close(SESSION_OPEN_FILE_TAB)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<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={props.browser.available()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
value={language.t("session.tab.add")}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("session.tab.add")}
|
||||
// The tablist redirects focus entering it to the selected
|
||||
// tab, which counts as focus-outside and closes the menu.
|
||||
onPointerDown={(event: PointerEvent) => event.preventDefault()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
onSelect={openFileBrowser}
|
||||
shortcut={
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="open-file" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<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>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
data-slot="session-side-panel-actions"
|
||||
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
|
||||
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppButton directory={projectDirectory} />
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.List>
|
||||
<div
|
||||
data-slot="session-side-panel-actions"
|
||||
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
|
||||
classList={{ "h-[51px]": !!props.stacked, "h-12": !props.stacked }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{extensions.tools()}
|
||||
<Show when={opened() && !props.mobile}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
{props.reviewPanel()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "context"}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<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
|
||||
id={fileBrowserTabPanelID}
|
||||
role="tabpanel"
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !fileBrowserVisible() }}
|
||||
inert={!fileBrowserVisible() || undefined}
|
||||
>
|
||||
<SessionFileBrowserTab
|
||||
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={
|
||||
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
}
|
||||
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
|
||||
kinds={kinds()}
|
||||
state={props.fileBrowserState}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
onSelectPermanent={(path) => openTab(file.tab(path))}
|
||||
filterRef={(element) => (fileFilter = element)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<ExtensionPanelContent panels={extensions.panels()} active={state.activeTab()} />
|
||||
</Tabs>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={sidebar()}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
style={{ width: `${width()}px` }}
|
||||
>
|
||||
{extensions.sidebar()}
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={width()}
|
||||
min={240}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={fileOpen()}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
style={{ width: treeWidth() }}
|
||||
>
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
>
|
||||
<Tabs
|
||||
variant="surface"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.review.filesChanged", { count: props.reviewCount })}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Show when={fileTreeTab() === "changes"}>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview || !props.diffsReady}>
|
||||
<Show
|
||||
when={props.diffsReady}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<Show when={fileTreeTab() === "all"}>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={fileTreeWidth()}
|
||||
min={FILE_TREE_WIDTH_MIN}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</aside>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { virtualScrollElement } from "@opencode/plugin-review-desktop/files/virtual-scroll"
|
||||
import { virtualScrollElement } from "./virtual-scroll"
|
||||
|
||||
test("resolves the connected viewport that owns the virtual root", () => {
|
||||
const stale = document.createElement("div")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function virtualScrollElement(root: HTMLElement | undefined) {
|
||||
if (!root?.isConnected) return null
|
||||
return root.closest<HTMLDivElement>(".scroll-view__viewport")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { For, Show, Suspense, lazy, createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
return { default: StatusDrawer }
|
||||
})
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
return { default: MobilePanelDrawer }
|
||||
})
|
||||
|
||||
export function SessionMobileViewTabs(props: {
|
||||
current: string
|
||||
items: readonly { id: string; title: string; menu?: boolean }[]
|
||||
onSelect(value: string): void
|
||||
details?: (close: () => void) => JSX.Element
|
||||
onDetailsOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ status: false, details: false })
|
||||
createEffect(() => props.onDetailsOpenChange?.(store.details))
|
||||
onCleanup(() => props.onDetailsOpenChange?.(false))
|
||||
return (
|
||||
<div class="relative flex shrink-0 items-center" data-slot="session-mobile-view-navigation">
|
||||
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
|
||||
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0">
|
||||
<Tabs.Trigger value="session" onClick={() => props.onSelect("session")}>
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<For each={props.items.filter((item) => !item.menu)}>
|
||||
{(item) => (
|
||||
<Tabs.Trigger value={item.id} onClick={() => props.onSelect(item.id)}>
|
||||
{item.title}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
<Tabs.Trigger value="terminal" onClick={() => props.onSelect("terminal")}>
|
||||
{language.t("terminal.title")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="menu" />}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={props.items.filter((item) => item.menu)}>
|
||||
{(item) => <Menu.Item onSelect={() => props.onSelect(item.id)}>{item.title}</Menu.Item>}
|
||||
</For>
|
||||
<Show when={props.details}>
|
||||
<Menu.Item onSelect={() => setStore("details", true)}>{language.t("session.summary.title")}</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={() => setStore("status", true)}>{language.t("status.popover.trigger")}</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.status}>
|
||||
<Suspense>
|
||||
<StatusDrawer open onOpenChange={(open) => setStore("status", open)} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={store.details}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
title={language.t("session.summary.title")}
|
||||
open
|
||||
onOpenChange={(open) => setStore("details", open)}
|
||||
>
|
||||
{props.details?.(() => setStore("details", false))}
|
||||
</MobilePanelDrawer>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
|
||||
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { createOpenReviewFile } from "../helpers"
|
||||
import type { SessionModel } from "../model"
|
||||
import type { SessionScreenLayout } from "../screen-layout"
|
||||
import { createReviewPanelState } from "./panel-state"
|
||||
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds"
|
||||
import type { DiffStyle } from "./review-tab"
|
||||
|
||||
export type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
export function createSessionReview(input: {
|
||||
session: SessionModel
|
||||
screen: SessionScreenLayout
|
||||
deferRender: Accessor<boolean>
|
||||
}) {
|
||||
const data = input.session.shared.data
|
||||
const queryClient = useQueryClient()
|
||||
const comments = useComments()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
const prompt = useComposerState()
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
const [state, setState] = createStore({
|
||||
mobileTab: "session" as "session" | "changes" | "files" | "usage",
|
||||
detailsOpen: false,
|
||||
scroll: undefined as HTMLDivElement | undefined,
|
||||
pendingFile: undefined as string | undefined,
|
||||
})
|
||||
const mode = () => input.session.layout.view().review.mode() ?? "git"
|
||||
const selectedFile = () => input.session.layout.view().review.file()
|
||||
createEffect(() => {
|
||||
const tab = input.session.tabs.activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) void file.load(path)
|
||||
})
|
||||
const vcs = createMemo(() => data.location.vcs.info({ directory: location().directory }))
|
||||
const options = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const project = input.session.project()
|
||||
if (project?.vcs) list.push("git")
|
||||
if (
|
||||
project?.vcs &&
|
||||
vcs()?.branch.current &&
|
||||
vcs()?.branch.default &&
|
||||
vcs()?.branch.current !== vcs()?.branch.default
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
return list
|
||||
})
|
||||
const mobileChanges = createMemo(
|
||||
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
|
||||
)
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
const value = mode()
|
||||
return value === "git" || value === "branch" ? value : undefined
|
||||
})
|
||||
const vcsKey = createMemo(
|
||||
() =>
|
||||
[
|
||||
server.scope,
|
||||
"session-vcs",
|
||||
location().directory,
|
||||
vcs()?.branch.current ?? "",
|
||||
vcs()?.branch.default ?? "",
|
||||
] as const,
|
||||
)
|
||||
const wantsReview = createMemo(() =>
|
||||
input.session.isDesktop()
|
||||
? input.screen.files.open() ||
|
||||
(input.screen.review.open() &&
|
||||
(input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab()))
|
||||
: mobileChanges(),
|
||||
)
|
||||
const vcsQuery = createQuery(() => {
|
||||
const value = vcsMode()
|
||||
return {
|
||||
queryKey: [...vcsKey(), value] as const,
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: value
|
||||
? () =>
|
||||
server.api.vcs
|
||||
.diff({
|
||||
location: { directory: location().directory },
|
||||
mode: value === "git" ? "working" : value,
|
||||
})
|
||||
.then((result) => result.data)
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const detailsQuery = createQuery(() => ({
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
|
||||
queryFn: () =>
|
||||
server.api.vcs
|
||||
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load session details diff", { error })
|
||||
return []
|
||||
}),
|
||||
}))
|
||||
const refresh = debounce(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
|
||||
})
|
||||
}, 100)
|
||||
createEffect(() => {
|
||||
const stop = location().event.listen((event) => {
|
||||
if (event.type === "filesystem.changed") refresh()
|
||||
})
|
||||
onCleanup(stop)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => input.screen.review.open() || mobileChanges(),
|
||||
(open, previous) => {
|
||||
if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return
|
||||
refresh()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const diffs = () => {
|
||||
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
|
||||
return []
|
||||
}
|
||||
const activeFile = () => {
|
||||
const list = diffs()
|
||||
const selected = selectedFile()
|
||||
if (selected && list.some((diff) => diff.file === selected)) return selected
|
||||
return list[0]?.file
|
||||
}
|
||||
const count = () => diffs().length
|
||||
const hasChanges = () => count() > 0
|
||||
const ready = () => {
|
||||
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
}
|
||||
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const value = vcsMode()
|
||||
if (!value) return undefined
|
||||
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
|
||||
const directory = reviewDiffDirectory(root, path)
|
||||
const source = diffs().find((diff) => diff.file === path)
|
||||
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
|
||||
if (!diff || !source) return undefined
|
||||
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return undefined
|
||||
if (reviewDiffNeedsLoad(diff)) return undefined
|
||||
return diff
|
||||
}
|
||||
const request = (scope: string, context?: number) =>
|
||||
queryClient
|
||||
.fetchQuery({
|
||||
queryKey: [server.scope, ...vcsKey(), value, "directory", scope, context, version] as const,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 2,
|
||||
queryFn: () =>
|
||||
server.api.vcs
|
||||
.diff({
|
||||
location: { directory: scope },
|
||||
mode: value === "git" ? "working" : value,
|
||||
context,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
})
|
||||
.then((result) => result.find((diff) => diff.file === path))
|
||||
|
||||
if (directory !== root) {
|
||||
try {
|
||||
const scoped = valid(await request(directory))
|
||||
if (scoped) return scoped
|
||||
} catch (error) {
|
||||
console.debug("[session-review] failed to load scoped vcs diff", { mode: value, path, directory, error })
|
||||
}
|
||||
}
|
||||
try {
|
||||
const bounded = valid(await request(root, 3))
|
||||
if (bounded) return bounded
|
||||
} catch (error) {
|
||||
console.debug("[session-review] failed to load bounded vcs diff", { mode: value, path, root, error })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const selectionPreview = (path: string, selection: FileSelection): string | undefined => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
|
||||
}
|
||||
const addComment = (comment: SessionReviewLineComment) => {
|
||||
const selection = selectionFromLines(comment.selection)
|
||||
const saved = comments.add({ file: comment.file, selection: comment.selection, comment: comment.comment })
|
||||
prompt.context.add({
|
||||
type: "file",
|
||||
path: comment.file,
|
||||
selection,
|
||||
comment: comment.comment,
|
||||
commentID: saved.id,
|
||||
commentOrigin: "review",
|
||||
preview: comment.preview ?? selectionPreview(comment.file, selection),
|
||||
})
|
||||
}
|
||||
const updateComment = (comment: {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
}) => {
|
||||
comments.update(comment.file, comment.id, comment.comment)
|
||||
prompt.context.updateComment(comment.file, comment.id, {
|
||||
comment: comment.comment,
|
||||
...(comment.preview ? { preview: comment.preview } : {}),
|
||||
})
|
||||
}
|
||||
const removeComment = (comment: { id: string; file: string }) => {
|
||||
comments.remove(comment.file, comment.id)
|
||||
prompt.context.removeComment(comment.file, comment.id)
|
||||
}
|
||||
const commentActions = createMemo(() => ({
|
||||
moreLabel: language.t("common.moreOptions"),
|
||||
editLabel: language.t("common.edit"),
|
||||
deleteLabel: language.t("common.delete"),
|
||||
saveLabel: language.t("common.save"),
|
||||
}))
|
||||
const showAllFiles = () => {
|
||||
if (layout.fileTree.tab() !== "changes") return
|
||||
layout.fileTree.setTab("all")
|
||||
}
|
||||
const open = () => {
|
||||
if (!input.session.layout.view().reviewPanel.opened()) input.session.layout.view().reviewPanel.open()
|
||||
}
|
||||
const openFile = createOpenReviewFile({
|
||||
showAllFiles,
|
||||
tabForPath: file.tab,
|
||||
openTab: (tab) => input.session.layout.tabs().open(tab),
|
||||
setActive: (tab) => input.session.layout.tabs().setActive(tab),
|
||||
loadFile: file.load,
|
||||
})
|
||||
const reviewDiffId = (path: string): string | undefined => {
|
||||
const sum = checksum(path)
|
||||
if (!sum) return undefined
|
||||
return `session-review-diff-${sum}`
|
||||
}
|
||||
const reviewDiffTop = (path: string): number | undefined => {
|
||||
if (!state.scroll) return undefined
|
||||
const id = reviewDiffId(path)
|
||||
if (!id) return undefined
|
||||
const element = document.getElementById(id)
|
||||
if (!(element instanceof HTMLElement) || !state.scroll.contains(element)) return undefined
|
||||
const target = element.getBoundingClientRect()
|
||||
const root = state.scroll.getBoundingClientRect()
|
||||
return target.top - root.top + state.scroll.scrollTop
|
||||
}
|
||||
const scrollToFile = (path: string) => {
|
||||
if (!state.scroll) return false
|
||||
const top = reviewDiffTop(path)
|
||||
if (top === undefined) return false
|
||||
input.session.layout.view().setScroll("review", { x: state.scroll.scrollLeft, y: top })
|
||||
state.scroll.scrollTo({ top, behavior: "auto" })
|
||||
return true
|
||||
}
|
||||
const focusFile = (path: string) => {
|
||||
open()
|
||||
input.session.layout.view().review.openPath(path)
|
||||
input.session.layout.view().review.setFile(path)
|
||||
setState("pendingFile", path)
|
||||
}
|
||||
createEffect(() => {
|
||||
const pending = state.pendingFile
|
||||
if (!pending || !state.scroll || !ready()) return
|
||||
const attempt = (count: number) => {
|
||||
if (state.pendingFile !== pending) return
|
||||
if (count > 60) {
|
||||
setState("pendingFile", undefined)
|
||||
return
|
||||
}
|
||||
if (!state.scroll || !scrollToFile(pending)) {
|
||||
requestAnimationFrame(() => attempt(count + 1))
|
||||
return
|
||||
}
|
||||
const top = reviewDiffTop(pending)
|
||||
if (top === undefined || Math.abs(state.scroll.scrollTop - top) > 1) {
|
||||
requestAnimationFrame(() => attempt(count + 1))
|
||||
return
|
||||
}
|
||||
setState("pendingFile", undefined)
|
||||
}
|
||||
requestAnimationFrame(() => attempt(0))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
input.session.tabs.activeFileTab,
|
||||
(active) => {
|
||||
if (!active || layout.fileTree.tab() !== "changes") return
|
||||
showAllFiles()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
let treeDirectory: string | undefined
|
||||
createEffect(() => {
|
||||
const directory = location().directory
|
||||
if (!input.session.isDesktop() || !layout.fileTree.opened() || server.connection.status() !== "connected") return
|
||||
layout.fileTree.tab()
|
||||
const refreshTree = treeDirectory !== directory
|
||||
treeDirectory = directory
|
||||
void (refreshTree ? file.tree.refresh("") : file.tree.list(""))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => location().directory,
|
||||
() => {
|
||||
const tab = input.session.tabs.activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) void file.load(path, { force: true })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!layout.ready() || server.connection.status() !== "connected" || !input.session.project()) return
|
||||
const list = options()
|
||||
const value = mode()
|
||||
if (list.includes(value)) return
|
||||
const next = list[0]
|
||||
if (next) input.session.layout.view().review.setMode(next)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => data.session.status(input.session.identity.params.id ?? ""),
|
||||
(next, previous) => {
|
||||
if (next !== "idle" || previous === undefined || previous === "idle") return
|
||||
refresh()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
input.session.identity.sessionKey,
|
||||
() => {
|
||||
setState("mobileTab", "session")
|
||||
setState("scroll", undefined)
|
||||
setState("pendingFile", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const panelState = createReviewPanelState()
|
||||
const panelRendered = createMemo<boolean>((previous) => previous || !input.deferRender(), false)
|
||||
return {
|
||||
activeFile,
|
||||
canReview: input.session.canReview,
|
||||
comments: {
|
||||
actions: commentActions,
|
||||
add: addComment,
|
||||
all: comments.all,
|
||||
focus: comments.focus,
|
||||
mentions: file.searchFilesAndDirectories,
|
||||
remove: removeComment,
|
||||
changeFocus: (focus: { file: string; id: string } | null) => {
|
||||
if (!focus) {
|
||||
const current = comments.focus()
|
||||
if (current && diffs().some((diff) => diff.file === current.file)) focusFile(current.file)
|
||||
}
|
||||
comments.setFocus(focus)
|
||||
},
|
||||
setFocus: comments.setFocus,
|
||||
update: updateComment,
|
||||
},
|
||||
count,
|
||||
deferRender: input.deferRender,
|
||||
details: {
|
||||
diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
|
||||
setOpen: (open: boolean) => setState("detailsOpen", open),
|
||||
},
|
||||
diffVersion: () => vcsQuery.dataUpdatedAt,
|
||||
diffStyle: {
|
||||
current: layout.review.diffStyle,
|
||||
set: (style: DiffStyle) => layout.review.setDiffStyle(style),
|
||||
},
|
||||
diffs,
|
||||
focusFile,
|
||||
hasChanges,
|
||||
loadDiff,
|
||||
mobile: {
|
||||
changes: mobileChanges,
|
||||
setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab),
|
||||
tab: () => state.mobileTab,
|
||||
},
|
||||
mode,
|
||||
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
|
||||
open,
|
||||
openFile,
|
||||
options,
|
||||
panelState,
|
||||
panelRendered,
|
||||
ready,
|
||||
screen: input.screen,
|
||||
setMode: (value: ChangeMode) => input.session.layout.view().review.setMode(value),
|
||||
setScroll: (element: HTMLDivElement | undefined) => setState("scroll", element),
|
||||
view: input.session.layout.view,
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionReviewModel = ReturnType<typeof createSessionReview>
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
type SessionReviewExpandMode,
|
||||
} from "@opencode/session-ui/v2/session-review-v2"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const ReviewPanel = Persistence.struct({
|
||||
sidebarOpened: Schema.Boolean,
|
||||
sidebarWidth: Schema.Finite.check(
|
||||
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
|
||||
),
|
||||
expandMode: Schema.Literals(["expand", "collapse"]),
|
||||
})
|
||||
|
||||
export function createReviewPanelState(platform?: Platform) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("review-panel-v2"),
|
||||
ReviewPanel,
|
||||
{ sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" },
|
||||
platform,
|
||||
)
|
||||
// The filter is transient by design: a persisted filter would silently hide
|
||||
// files after a reload.
|
||||
const [filter, setFilter] = createSignal("")
|
||||
|
||||
return {
|
||||
sidebarOpened: () => store.sidebarOpened,
|
||||
sidebarWidth: () => store.sidebarWidth,
|
||||
sidebarTransition: ready,
|
||||
filter,
|
||||
setFilter,
|
||||
expandMode: () => store.expandMode,
|
||||
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
|
||||
resizeSidebar: (width: number) =>
|
||||
setStore(
|
||||
"sidebarWidth",
|
||||
Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
|
||||
),
|
||||
toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
|
||||
}
|
||||
}
|
||||
|
||||
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
|
||||
+28
-43
@@ -17,18 +17,20 @@ import type {
|
||||
SessionReviewFocus,
|
||||
SessionReviewLineComment,
|
||||
} from "@opencode/session-ui/session-review"
|
||||
import FileTreeV2 from "../files/file-tree-v2"
|
||||
import { sortFileTreeV2Paths } from "../files/file-tree-v2-model"
|
||||
import { useLanguage, useWorkspaceLocation, useServerSDK } from "../environment"
|
||||
import FileTreeV2 from "@/session/files/file-tree-v2"
|
||||
import { sortFileTreeV2Paths } from "@/session/files/file-tree-v2-model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import {
|
||||
filterRenderableDiff,
|
||||
filterReviewFiles,
|
||||
reviewDiffKinds,
|
||||
reviewDiffNeedsLoad,
|
||||
type RenderDiff,
|
||||
} from "./review-diff-kinds"
|
||||
import type { ReviewPanelState } from "./panel-state"
|
||||
import { applyFileListKeyDown, SessionFileList, FileListView } from "../files/list"
|
||||
} from "@/session/review/review-diff-kinds"
|
||||
import type { ReviewPanelState } from "@/session/review/panel-state"
|
||||
import { applyFileListKeyDown, SessionFileList } from "@/session/files/list"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
|
||||
@@ -55,7 +57,6 @@ export type ReviewPanelProps = {
|
||||
}
|
||||
|
||||
export function ReviewPanel(props: ReviewPanelProps) {
|
||||
const language = useLanguage()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const readFile = async (path: string) =>
|
||||
@@ -67,18 +68,11 @@ export function ReviewPanel(props: ReviewPanelProps) {
|
||||
return undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<ReviewPanelView
|
||||
{...props}
|
||||
readFile={readFile}
|
||||
labels={{ loading: language.t("common.loading"), empty: language.t("palette.empty") }}
|
||||
/>
|
||||
)
|
||||
return <ReviewPanelView {...props} readFile={readFile} />
|
||||
}
|
||||
|
||||
export function ReviewPanelView(
|
||||
props: ReviewPanelProps & {
|
||||
labels: { loading: string; empty: string }
|
||||
readFile?: (path: string) => Promise<{ type: "text"; content: string } | undefined>
|
||||
},
|
||||
) {
|
||||
@@ -138,7 +132,6 @@ export function ReviewPanelView(
|
||||
// Always mounted: the sidebar header hosts the changes-mode dropdown,
|
||||
// which must stay reachable when the current mode has zero diffs.
|
||||
<ReviewPanelSidebar
|
||||
labels={props.labels}
|
||||
title={props.title}
|
||||
state={props.state}
|
||||
diffsReady={props.diffsReady}
|
||||
@@ -190,7 +183,6 @@ export function ReviewPanelView(
|
||||
}
|
||||
|
||||
function ReviewPanelSidebar(props: {
|
||||
labels: { loading: string; empty: string }
|
||||
title?: JSX.Element
|
||||
state: ReviewPanelState
|
||||
diffsReady: boolean
|
||||
@@ -202,6 +194,7 @@ function ReviewPanelSidebar(props: {
|
||||
activeDiff: string | undefined
|
||||
flat: boolean
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
|
||||
const highlightedPath = createMemo(() => {
|
||||
if (!props.searching) return undefined
|
||||
@@ -236,7 +229,12 @@ function ReviewPanelSidebar(props: {
|
||||
>
|
||||
<Show
|
||||
when={props.diffsReady}
|
||||
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{props.labels.loading}</div>}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searching}
|
||||
@@ -253,7 +251,7 @@ function ReviewPanelSidebar(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FileListView
|
||||
<SessionFileList
|
||||
files={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
active={props.activeDiff}
|
||||
@@ -264,31 +262,18 @@ function ReviewPanelSidebar(props: {
|
||||
>
|
||||
<Show
|
||||
when={props.filteredFiles.length > 0}
|
||||
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{props.labels.empty}</div>}
|
||||
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
|
||||
>
|
||||
<Show
|
||||
when={!props.flat}
|
||||
fallback={
|
||||
<FileListView
|
||||
files={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
active={props.activeDiff}
|
||||
highlighted={highlightedPath()}
|
||||
onFileClick={props.onSelectFile}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionFileList
|
||||
files={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
active={props.activeDiff}
|
||||
highlighted={highlightedPath()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
props.onSelectFile(path)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<SessionFileList
|
||||
files={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
active={props.activeDiff}
|
||||
highlighted={highlightedPath()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
props.onSelectFile(path)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { Kind } from "../files/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "../files/file-tree-v2-model"
|
||||
import type { Kind } from "@/session/files/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/session/files/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = FileDiffInfo
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import { SessionReview } from "@opencode/session-ui/session-review"
|
||||
import type {
|
||||
SessionReviewCommentActions,
|
||||
SessionReviewCommentDelete,
|
||||
SessionReviewCommentUpdate,
|
||||
} from "@opencode/session-ui/session-review"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import type { LineComment } from "@/composer/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: ReviewDiff[]
|
||||
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
changeSummary?: boolean
|
||||
overflow?: "wrap" | "scroll"
|
||||
disableLineNumbers?: boolean
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
onViewFile?: (file: string) => void
|
||||
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
|
||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||
lineCommentActions?: SessionReviewCommentActions
|
||||
comments?: LineComment[]
|
||||
focusedComment?: { file: string; id: string } | null
|
||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement | undefined) => void
|
||||
commentMentions?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
classes?: {
|
||||
root?: string
|
||||
header?: string
|
||||
container?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let userInteracted = false
|
||||
let restored: { x: number; y: number } | undefined
|
||||
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const layout = useLayout()
|
||||
|
||||
const readFile = async (path: string) => {
|
||||
return serverSDK.api.file
|
||||
.read({ path, location: { directory: sdk().directory } })
|
||||
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to read file", { path, error })
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
const handleInteraction = () => {
|
||||
userInteracted = true
|
||||
|
||||
if (restoreFrame !== undefined) {
|
||||
cancelAnimationFrame(restoreFrame)
|
||||
restoreFrame = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const doRestore = () => {
|
||||
restoreFrame = undefined
|
||||
const el = scroll
|
||||
if (!el || !layout.ready() || userInteracted) return
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) return
|
||||
|
||||
const s = props.view.scroll("review")
|
||||
if (!s || (s.x === 0 && s.y === 0)) return
|
||||
|
||||
const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
|
||||
|
||||
const targetY = Math.min(s.y, maxY)
|
||||
const targetX = Math.min(s.x, maxX)
|
||||
|
||||
if (el.scrollTop === targetY && el.scrollLeft === targetX) return
|
||||
|
||||
if (el.scrollTop !== targetY) el.scrollTop = targetY
|
||||
if (el.scrollLeft !== targetX) el.scrollLeft = targetX
|
||||
restored = { x: el.scrollLeft, y: el.scrollTop }
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (userInteracted || restoreFrame !== undefined) return
|
||||
restoreFrame = requestAnimationFrame(doRestore)
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
const el = event.currentTarget
|
||||
const prev = restored
|
||||
if (prev && el.scrollTop === prev.y && el.scrollLeft === prev.x) {
|
||||
restored = undefined
|
||||
return
|
||||
}
|
||||
|
||||
restored = undefined
|
||||
handleInteraction()
|
||||
if (!layout.ready()) return
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) return
|
||||
|
||||
props.view.setScroll("review", {
|
||||
x: el.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.diffs.length
|
||||
props.diffStyle
|
||||
props.overflow
|
||||
if (!layout.ready()) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
props.onScrollRef?.(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionReview
|
||||
title={props.title}
|
||||
empty={props.empty}
|
||||
scrollRef={(el) => {
|
||||
scroll = el
|
||||
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "keydown", handleInteraction, { capture: true })
|
||||
props.onScrollRef?.(el)
|
||||
queueRestore()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onDiffRendered={queueRestore}
|
||||
open={props.view.review.open()}
|
||||
onOpenChange={props.view.review.setOpen}
|
||||
classes={{
|
||||
root: props.classes?.root ?? "pr-3",
|
||||
header: props.classes?.header ?? "px-3",
|
||||
container: props.classes?.container ?? "pl-3",
|
||||
}}
|
||||
diffs={props.diffs}
|
||||
diffStyle={props.diffStyle}
|
||||
changeSummary={props.changeSummary}
|
||||
overflow={props.overflow}
|
||||
disableLineNumbers={props.disableLineNumbers}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
onViewFile={props.onViewFile}
|
||||
focusedFile={props.focusedFile}
|
||||
readFile={readFile}
|
||||
onLineComment={props.onLineComment}
|
||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
lineCommentActions={props.lineCommentActions}
|
||||
lineCommentMention={props.commentMentions}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
return { default: StatusDrawer }
|
||||
})
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
return { default: MobilePanelDrawer }
|
||||
})
|
||||
|
||||
export function SessionMobileViewTabs(props: {
|
||||
current: "session" | "changes" | "files" | "usage" | "terminal"
|
||||
onSelect: (view: "session" | "changes" | "files" | "usage" | "terminal") => void
|
||||
details?: (close: () => void) => JSX.Element
|
||||
onDetailsOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
menu: false,
|
||||
status: false,
|
||||
statusLoaded: false,
|
||||
details: false,
|
||||
detailsLoaded: false,
|
||||
pending: undefined as "status" | "details" | undefined,
|
||||
})
|
||||
createEffect(() => props.onDetailsOpenChange?.(store.details))
|
||||
onCleanup(() => props.onDetailsOpenChange?.(false))
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
return (
|
||||
<div
|
||||
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
|
||||
data-slot="session-mobile-view-navigation"
|
||||
>
|
||||
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
|
||||
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
|
||||
<For each={["session", "changes", "files", "terminal"] as const}>
|
||||
{(view) => (
|
||||
<Tabs.Trigger
|
||||
value={view}
|
||||
class="min-w-0 flex-1"
|
||||
classes={{ button: "w-full justify-center" }}
|
||||
onClick={() => props.onSelect(view)}
|
||||
>
|
||||
{view === "session"
|
||||
? language.t("session.tab.session")
|
||||
: view === "changes"
|
||||
? language.plural("session.review.change", 0)
|
||||
: view === "files"
|
||||
? language.t("session.tab.files")
|
||||
: language.t("terminal.title")}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Menu
|
||||
appearance="standard"
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
open={store.menu}
|
||||
onOpenChange={(open) => setStore("menu", open)}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
trigger = element
|
||||
}}
|
||||
icon={<Icon name="menu" />}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="mx-1.5 shrink-0"
|
||||
state={props.current === "usage" || store.menu ? "pressed" : undefined}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!store.pending) return
|
||||
event.preventDefault()
|
||||
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
|
||||
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
|
||||
setStore("pending", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
|
||||
<Show when={props.details}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
|
||||
{language.t("session.summary.title")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
|
||||
{language.t("status.popover.trigger")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.statusLoaded}>
|
||||
<Suspense>
|
||||
<StatusDrawer
|
||||
open={store.status}
|
||||
onOpenChange={(open) => setStore("status", open)}
|
||||
returnFocus={() => trigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={store.detailsLoaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
title={language.t("session.summary.title")}
|
||||
open={store.details}
|
||||
onOpenChange={(open) => setStore("details", open)}
|
||||
returnFocus={() => trigger}
|
||||
>
|
||||
{props.details?.(() => setStore("details", false))}
|
||||
</MobilePanelDrawer>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
return (
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<ReviewContent review={props.review} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={props.review.canReview()}
|
||||
diffs={props.review.diffs()}
|
||||
diffsReady={props.review.ready()}
|
||||
hasReview={props.review.hasChanges()}
|
||||
reviewHasFocusableContent={props.review.hasChanges() || props.review.panelState.sidebarOpened()}
|
||||
reviewCount={props.review.count()}
|
||||
reviewPanel={() => <ReviewPanelContent review={props.review} />}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={props.review.panelState.sidebarOpened()}
|
||||
disabled={disabled}
|
||||
onToggle={props.review.panelState.toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
fileBrowserState={props.review.panelState}
|
||||
activeDiff={props.review.activeFile()}
|
||||
focusReviewDiff={props.review.focusFile}
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewContent(props: { review: SessionReviewModel }) {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={!props.review.deferRender()}>
|
||||
<SessionReviewTab
|
||||
title={<ReviewTitle review={props.review} />}
|
||||
empty={<ReviewEmpty review={props.review} loadingClass="px-2 py-2 text-text-weak" />}
|
||||
diffs={props.review.diffs()}
|
||||
view={props.review.view()}
|
||||
diffStyle="unified"
|
||||
changeSummary
|
||||
disableLineNumbers={false}
|
||||
overflow={settings.general.mobileDiffWrap() ? "wrap" : "scroll"}
|
||||
onViewFile={(file) => {
|
||||
props.review.openFile(file)
|
||||
props.review.mobile.setTab("files")
|
||||
}}
|
||||
onScrollRef={props.review.setScroll}
|
||||
focusedFile={props.review.activeFile()}
|
||||
onLineComment={props.review.comments.add}
|
||||
onLineCommentUpdate={props.review.comments.update}
|
||||
onLineCommentDelete={props.review.comments.remove}
|
||||
lineCommentActions={props.review.comments.actions()}
|
||||
commentMentions={{ items: props.review.comments.mentions }}
|
||||
comments={props.review.comments.all()}
|
||||
focusedComment={props.review.comments.focus()}
|
||||
onFocusedCommentChange={props.review.comments.setFocus}
|
||||
classes={{
|
||||
root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0",
|
||||
header:
|
||||
"!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']",
|
||||
container: "!px-0",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewPanelContent(props: { review: SessionReviewModel }) {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
|
||||
<Show when={props.review.panelRendered()}>
|
||||
<ReviewPanel
|
||||
title={<ReviewTitle review={props.review} />}
|
||||
empty={<ReviewPanelEmpty review={props.review} />}
|
||||
diffs={props.review.diffs()}
|
||||
diffsReady={props.review.ready()}
|
||||
diffVersion={props.review.diffVersion()}
|
||||
loadDiff={props.review.loadDiff}
|
||||
activeFile={props.review.activeFile()}
|
||||
onSelectFile={props.review.focusFile}
|
||||
diffStyle={props.review.diffStyle.current()}
|
||||
onDiffStyleChange={props.review.diffStyle.set}
|
||||
state={props.review.panelState}
|
||||
onLineComment={props.review.comments.add}
|
||||
onLineCommentUpdate={props.review.comments.update}
|
||||
onLineCommentDelete={props.review.comments.remove}
|
||||
lineCommentActions={props.review.comments.actions()}
|
||||
comments={props.review.comments.all()}
|
||||
focusedComment={props.review.comments.focus()}
|
||||
onFocusedCommentChange={props.review.comments.changeFocus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewTitle(props: { review: SessionReviewModel }) {
|
||||
const language = useLanguage()
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
return (
|
||||
<Show when={props.review.canReview()}>
|
||||
<Select
|
||||
options={props.review.options()}
|
||||
current={props.review.mode()}
|
||||
label={label}
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && props.review.setMode(option)}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
|
||||
return language.t("session.review.noChanges")
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
|
||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text()}</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewEmptyChangesV2 />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
@@ -23,9 +23,9 @@ import { TerminalPanel } from "@/session/terminal/panel"
|
||||
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionScreenLayout } from "./screen-layout"
|
||||
import { createSessionSummary } from "./summary"
|
||||
import { SessionMobileViewTabs } from "./mobile-view-tabs"
|
||||
import { SessionSidePanel } from "./files/session-side-panel"
|
||||
import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
|
||||
import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { createTimelineSearchController } from "./timeline/search-controller"
|
||||
import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
@@ -33,9 +33,12 @@ 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 { ExtensionPanelContent } from "@/extensions/content"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
return { default: SessionMobileFiles }
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
@@ -45,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({
|
||||
@@ -71,7 +67,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
sideTerminalPresent: false,
|
||||
mobileTerminalCached: false,
|
||||
mobileMoveDismissed: false,
|
||||
mobileTab: "session",
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
@@ -140,19 +135,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
}
|
||||
return key
|
||||
})
|
||||
const summary = createSessionSummary(session)
|
||||
const mobileView = createMemo(() => {
|
||||
if (screen.terminal.open()) return "terminal"
|
||||
if (store.mobileTab === "session") return store.mobileTab
|
||||
const selected = extensions.panels().find((panel) => panel.key === session.tabs.activeTab())
|
||||
return selected?.props.group ?? selected?.key ?? store.mobileTab
|
||||
})
|
||||
const mobileItems = createMemo(() =>
|
||||
Array.from(new Set(extensions.panels().map((panel) => panel.props.group ?? panel.key))).map((id) => {
|
||||
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)!
|
||||
return { id, title: panel.props.title, menu: panel.props.initial === "closed" && !panel.props.group }
|
||||
}),
|
||||
)
|
||||
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
|
||||
const mobileView = createMemo(() => (screen.terminal.open() ? "terminal" : review.mobile.tab()))
|
||||
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
|
||||
createEffect(() => {
|
||||
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
|
||||
@@ -174,9 +158,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<Show when={session.identity.sessionKey()} keyed>
|
||||
{(_key) => (
|
||||
<SessionMobileViewTabs
|
||||
items={mobileItems()}
|
||||
current={mobileView()}
|
||||
onDetailsOpenChange={summary.setOpen}
|
||||
onDetailsOpenChange={review.details.setOpen}
|
||||
details={
|
||||
!session.data.isChild() && detailsProject()
|
||||
? (close) => (
|
||||
@@ -194,18 +177,14 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
baseBranch={
|
||||
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
|
||||
}
|
||||
diffs={project().vcs ? summary.diffs() : []}
|
||||
diffs={project().vcs ? review.details.diffs() : []}
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
moveEligible={composer.workspaceMoveEligible()}
|
||||
moveDismissed={store.mobileMoveDismissed}
|
||||
onMoveDismiss={() => setStore("mobileMoveDismissed", true)}
|
||||
onReview={() => {
|
||||
close()
|
||||
const panel = extensions.defaultPanel()
|
||||
if (panel) {
|
||||
session.layout.tabs().setActive(panel)
|
||||
setStore("mobileTab", panel)
|
||||
}
|
||||
review.mobile.setTab("changes")
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
backgroundTasks={composer.requests.background.tasks()}
|
||||
@@ -220,13 +199,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
session.layout.view().terminal.open()
|
||||
return
|
||||
}
|
||||
setStore("mobileTab", view)
|
||||
const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === view)
|
||||
if (panel) {
|
||||
void session.layout.tabs().open(panel.key)
|
||||
session.layout.tabs().setActive(panel.key)
|
||||
session.layout.view().reviewPanel.open()
|
||||
}
|
||||
review.mobile.setTab(view)
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
/>
|
||||
@@ -258,8 +231,16 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<Match when={!isDesktop() && mobileView() === "terminal"}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={!isDesktop() && mobileItems().some((item) => item.id === mobileView())}>
|
||||
<ExtensionPanelContent panels={extensions.panels()} active={session.tabs.activeTab()} />
|
||||
<Match when={!isDesktop() && mobileView() === "usage"}>
|
||||
<SessionContextTab />
|
||||
</Match>
|
||||
<Match when={!isDesktop() && mobileView() === "files"}>
|
||||
<Suspense>
|
||||
<SessionMobileFiles />
|
||||
</Suspense>
|
||||
</Match>
|
||||
<Match when={session.identity.params.id && review.mobile.changes()}>
|
||||
<SessionMobileReview review={review} />
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
@@ -268,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}
|
||||
@@ -285,14 +265,10 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
pinned={timeline.view.pinned()}
|
||||
centered={screen.centered()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
diffs={summary.diffs}
|
||||
onReview={() => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const panel = extensions.defaultPanel()
|
||||
if (panel) session.layout.tabs().setActive(panel)
|
||||
}}
|
||||
diffs={review.details.diffs}
|
||||
onReview={review.open}
|
||||
workspaceMoveEligible={composer.workspaceMoveEligible()}
|
||||
onSummaryOpenChange={summary.setOpen}
|
||||
onSummaryOpenChange={review.details.setOpen}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
@@ -312,7 +288,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{extensions.declarations()}
|
||||
<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. */}
|
||||
@@ -404,12 +379,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionSidePanel
|
||||
size={screen.size}
|
||||
stacked={screen.side.layout().stacked}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ReviewPanelView } from "@opencode/plugin-review-desktop/review/panel"
|
||||
import { createReviewPanelState } from "@opencode/plugin-review-desktop/review/panel-state"
|
||||
import { ReviewPanelView } from "@/session/review/panel"
|
||||
import { createReviewPanelState } from "@/session/review/panel-state"
|
||||
import { TerminalSurface } from "@/session/terminal/surface"
|
||||
import type { WebSearchRequestModel } from "./requests/websearch"
|
||||
|
||||
@@ -365,7 +365,6 @@ function SessionReviewPane(props: { diffs: SessionDocument["diffs"] }) {
|
||||
})
|
||||
return (
|
||||
<ReviewPanelView
|
||||
labels={{ loading: language.t("common.loading"), empty: language.t("palette.empty") }}
|
||||
title={language.t("ui.sessionReview.title.lastTurn")}
|
||||
empty={<SessionReviewEmptyChangesV2 />}
|
||||
diffs={props.diffs}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { SessionModel } from "./model"
|
||||
|
||||
export function createSessionSummary(session: SessionModel) {
|
||||
const server = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const queryClient = useQueryClient()
|
||||
const [state, setState] = createStore({ open: false })
|
||||
const key = () => [server.scope, "session-details", session.workspace.directory()]
|
||||
const query = createQuery(() => ({
|
||||
queryKey: key(),
|
||||
enabled: state.open && server.connection.status() === "connected" && !!session.project()?.vcs,
|
||||
queryFn: () =>
|
||||
server.api.vcs
|
||||
.diff({ location: { directory: session.workspace.directory() }, mode: "working" })
|
||||
.then((result) => result.data),
|
||||
}))
|
||||
createEffect(() => {
|
||||
onCleanup(
|
||||
location().event.listen((event) => {
|
||||
if (event.type === "filesystem.changed") void queryClient.invalidateQueries({ queryKey: key() })
|
||||
}),
|
||||
)
|
||||
})
|
||||
return { diffs: () => query.data, setOpen: (value: boolean) => setState("open", value) }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user