mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 12:10:01 -04:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea5ed2866a | |||
| 4de91c9755 | |||
| 89e3329bd5 | |||
| 17a5b32270 | |||
| 6721ff5328 | |||
| 026e0c634f | |||
| c581b59b7f | |||
| 5cc7811ec2 | |||
| 5d32845b02 | |||
| e9f5842f29 | |||
| 7b9734593a | |||
| 1dbfc7cfab | |||
| 3441b95afc | |||
| 306204bad8 | |||
| fca453a2a5 | |||
| 2a51cf5f88 | |||
| b7bd2b0ae0 | |||
| 45b2759a1e | |||
| 65b6abc4bb | |||
| 8c8492ef97 | |||
| 115e689723 | |||
| 7c30a4230e | |||
| bd4557291b | |||
| 9ecf46bfea | |||
| 47c8d85904 | |||
| c401076b6f | |||
| 9ded81b6c2 | |||
| 87142c1b4c | |||
| d8320209c9 | |||
| 31c72b9a17 | |||
| 1aef4de853 | |||
| 9c94634515 | |||
| c2b754bb03 | |||
| 2372edd5eb | |||
| a5f7f8d3b5 | |||
| 4df276b9e8 | |||
| 518af92c5b | |||
| 5d8c48718b | |||
| 41c5853739 | |||
| 8d989f6829 | |||
| 753fa4cd60 | |||
| d99a268ea5 | |||
| 1113adfd5e | |||
| 6afff5a55f | |||
| 71f5e4189d | |||
| b49bad9a86 | |||
| 2580f880a8 | |||
| 9d34029cd9 | |||
| 33296e7959 | |||
| 283258e95b | |||
| d7a7256bb6 | |||
| eec55fa5f8 | |||
| 7ff27ca8cf | |||
| b62dc4a636 | |||
| ceec9f5b66 | |||
| bbb1b5e7d0 | |||
| f0b8ad1242 | |||
| be53e3bd81 | |||
| c22942c1f3 | |||
| 6895728add | |||
| 16aad9e6ad | |||
| 9ab3029947 | |||
| 3c9f857bf1 | |||
| 5e16a5dc66 |
@@ -75,7 +75,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -377,7 +377,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -393,6 +393,7 @@ jobs:
|
||||
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
@@ -496,29 +497,31 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -577,6 +578,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"entities": "7.0.1",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -612,9 +614,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -902,7 +904,6 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
@@ -1088,9 +1089,9 @@
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -1108,9 +1109,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2168,27 +2169,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||
"@opentui/core": ["@opentui/core@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-darwin-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-x64": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y9MKL8LMup4ebW3uZ57RSsPyOv37sIZxuWiHq4XJK9wrzpzTMnVomzxpWOI0S/QtLH2QzZ7znnbF+IuvIQ1hLQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "x64" }, "sha512-lrVskzbjcT9yJCUgjOpvPpEq2ipmuDyHTMJ+OebsJFdATdryJLzRAr5BZiFGXEo4CJ2CS/+6vT+ki7dmhpM+0g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-6nixv6wlgjelUVJVHkJsCpuARb1rE/oxhIIthyLd062u5veoBt83cRGH9KmcZiwowKvso58hXn2KC21qAfylhQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-7zVbZ0XgCDi3yImmd9Cj+e0WhSuQzWo1ziSQiFgPCR8cs/dfrpYLgBlX9PP0gFOcJKeRccJJv7rnBetl3CB7tg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "x64" }, "sha512-3JCpPS8+Gz3PWlQHiFwqYuJ0AM0vKlfbe4+O+0m2EYej/QjN5KjL1nBfVLq5XDj4dVPEIktQKN6e//YzSzdaqA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@opentui/core": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260808-9ecf7c0a", "@opentui/solid": "0.0.0-20260808-9ecf7c0a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-aGxw6P0RJSuUU3y3QtAnKa1B6tQShxR0mjmnN3Cadr4LoduSuWn/l1kE+27KwH0ap9WTAeGVsN7u2oawB9eWKw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260808-9ecf7c0a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Q7ouv2KxZKO0/Sh0RbhNd7kEw6E7tlrU+yw36xxlnyXOqlK3TkQ+u1RMSZulYAn8mP20pnYLM9OxY4pOC/wK8Q=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -3538,7 +3539,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3622,8 +3623,6 @@
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
|
||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
@@ -4040,7 +4039,7 @@
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
@@ -4322,7 +4321,7 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
@@ -4466,8 +4465,6 @@
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -4806,7 +4803,7 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
@@ -4932,7 +4929,7 @@
|
||||
|
||||
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
@@ -4958,7 +4955,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
@@ -5560,7 +5557,7 @@
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
@@ -5592,8 +5589,6 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -6532,8 +6527,6 @@
|
||||
|
||||
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
|
||||
|
||||
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
@@ -6718,9 +6711,11 @@
|
||||
|
||||
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
|
||||
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
@@ -6796,8 +6791,6 @@
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -7564,20 +7557,6 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -8638,8 +8617,6 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
+6
-6
@@ -46,9 +46,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -150,9 +150,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
||||
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
|
||||
// for any field name that looks sensitive) plus literal (replace any actual
|
||||
// secret values we sent in the request, in case the response echoes one back).
|
||||
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
Array.from(secretValues(request)).reduce(
|
||||
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
|
||||
Array.from(secrets).reduce(
|
||||
(text, secret) => text.split(secret).join(REDACTED),
|
||||
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
||||
)
|
||||
|
||||
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
|
||||
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
|
||||
if (body === undefined) return {}
|
||||
const redacted = redactBody(body, request)
|
||||
const redacted = redactBody(body, secrets)
|
||||
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||
}
|
||||
@@ -240,7 +240,7 @@ const statusError =
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
const details = responseBody(body, secretValues(request))
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
@@ -261,6 +261,42 @@ const statusError =
|
||||
})
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
|
||||
// executor-driven requests produce. The originating request is not available on
|
||||
// that path, so the method is assumed (language model calls are always POST),
|
||||
// request headers are empty, and only structural body redaction applies.
|
||||
export const classifyHttpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly responseHeaders?: Record<string, string> | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
}) => {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
||||
|
||||
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
})
|
||||
await mockServer(page, { questions: [] })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await transport.waitForConnection({ path: "/api/event" })
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
@@ -132,32 +132,40 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
}),
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-caret",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue",
|
||||
question: "Continue?",
|
||||
options: [{ label: "Yes", description: "Continue the session" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||
await transport.send(
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-caret",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue",
|
||||
question: "Continue?",
|
||||
options: [{ label: "Yes", description: "Continue the session" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
undefined,
|
||||
"/api/event",
|
||||
)
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||
})
|
||||
await transport.send(
|
||||
{
|
||||
directory,
|
||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||
},
|
||||
undefined,
|
||||
"/api/event",
|
||||
)
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
await page.keyboard.press("x")
|
||||
|
||||
@@ -17,7 +17,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const historyPageSize = 50
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
|
||||
@@ -89,13 +89,15 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
const first = await timeline.transport.send(
|
||||
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||
{ id: "timeline-event-7" },
|
||||
"/api/event",
|
||||
)
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
await timeline.transport.error("retry with event id", "/api/event")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const root = "C:/OpenCode/WorkspaceProject"
|
||||
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||
const project = {
|
||||
id: "proj_workspaces",
|
||||
canonical: root,
|
||||
vcs: "git" as const,
|
||||
name: "workspace-project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
}
|
||||
const diff = {
|
||||
file: "src/workspace.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||
}
|
||||
const cors = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
||||
"access-control-allow-headers": "content-type",
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, title?: string): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: project.id,
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", id: "test" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title,
|
||||
location: { directory },
|
||||
subpath: "",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
function userMessage(id: string, text: string) {
|
||||
return { id, type: "user" as const, time: { created: 1 }, text }
|
||||
}
|
||||
|
||||
async function json(route: Route, body: unknown) {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", headers: cors, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
async function init(page: Page, tab: Record<string, unknown>) {
|
||||
await page.addInitScript(
|
||||
({ root, server, tab }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||
},
|
||||
{ root, server, tab },
|
||||
)
|
||||
}
|
||||
|
||||
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||
const draftID = "draft_workspaces"
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
const directories = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await directories
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "feature", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||
const draftID = "draft_workspace_settings"
|
||||
const cleanWorkspace = `${workspace}-clean`
|
||||
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||
let releaseSessions = () => {}
|
||||
const sessionsReady = new Promise<void>((resolve) => {
|
||||
releaseSessions = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project: inventory,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/session**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (
|
||||
route.request().method() !== "GET" ||
|
||||
url.pathname !== "/api/session" ||
|
||||
url.searchParams.get("limit") !== "100" ||
|
||||
url.searchParams.get("order") !== "desc"
|
||||
)
|
||||
return route.fallback()
|
||||
await sessionsReady
|
||||
await json(route, { data: [], cursor: {} })
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
const directories = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await directories
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||
const sessions = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === "/api/session" &&
|
||||
new URL(request.url()).searchParams.get("limit") === "100",
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "View all", exact: true }).click()
|
||||
await sessions
|
||||
|
||||
const settings = page.getByRole("dialog")
|
||||
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||
await expect(page.locator('[data-component="session-new-design"]')).toBeAttached()
|
||||
releaseSessions()
|
||||
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toBeVisible()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||
const confirmation = page.getByRole("dialog").filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||
const removed = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
await confirmation.getByRole("button", { name: "Delete workspace", exact: true }).click()
|
||||
const request = await removed
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(root)
|
||||
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace, force: true })
|
||||
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("submits the owning prompt after a new workspace is created", async ({ page }) => {
|
||||
const draftID = "draft_workspace_submit"
|
||||
const sessionID = "ses_workspace_submit"
|
||||
const createdSession = session(sessionID, createdWorkspace)
|
||||
let releaseCopy = () => {}
|
||||
const copyReady = new Promise<void>((resolve) => {
|
||||
releaseCopy = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() !== "POST") return route.fallback()
|
||||
await copyReady
|
||||
await json(route, { directory: createdWorkspace })
|
||||
})
|
||||
await page.route("**/api/session**", async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const promptPath = `/api/session/${sessionID}/prompt`
|
||||
if (request.method() === "OPTIONS" && (url.pathname === "/api/session" || url.pathname === promptPath))
|
||||
return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() === "POST" && url.pathname === "/api/session")
|
||||
return json(route, { data: createdSession })
|
||||
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||
return json(route, { data: createdSession })
|
||||
if (request.method() !== "POST" || url.pathname !== promptPath) return route.fallback()
|
||||
const input = request.postDataJSON() as { id: string; text: string }
|
||||
await json(route, {
|
||||
data: {
|
||||
id: input.id,
|
||||
sessionID,
|
||||
timeCreated: 3,
|
||||
type: "user",
|
||||
data: { text: input.text },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
const editor = page.getByRole("textbox", { name: "Prompt" })
|
||||
await expectAppVisible(editor)
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await editor.fill("Build workspace support")
|
||||
|
||||
const copied = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
const created = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/api/session",
|
||||
)
|
||||
const sent = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
await page.getByRole("button", { name: "Send", exact: true }).click()
|
||||
|
||||
const copyRequest = await copied
|
||||
expect(new URL(copyRequest.url()).searchParams.get("location[directory]")).toBe(root)
|
||||
expect(copyRequest.postDataJSON()).toEqual({ strategy: "git_worktree", directory: "C:/OpenCode" })
|
||||
releaseCopy()
|
||||
|
||||
expect((await created).postDataJSON()).toEqual({
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
location: { directory: createdWorkspace },
|
||||
})
|
||||
const promptRequest = await sent
|
||||
expect(promptRequest.postDataJSON()).toEqual({
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "Build workspace support",
|
||||
files: [],
|
||||
agents: [],
|
||||
})
|
||||
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessionID = "ses_workspace_move_new"
|
||||
const messageID = "msg_workspace_move_new"
|
||||
const currentSession = session(sessionID, root, "Create a workspace")
|
||||
const transport = await installSseTransport<OpenCodeEvent>(page, { server })
|
||||
let releaseCopy = () => {}
|
||||
const copyReady = new Promise<void>((resolve) => {
|
||||
releaseCopy = resolve
|
||||
})
|
||||
let releaseMove = () => {}
|
||||
const moveReady = new Promise<void>((resolve) => {
|
||||
releaseMove = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [currentSession],
|
||||
pageMessages: () => ({ items: [userMessage(messageID, "Create isolated workspace")] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() !== "POST") return route.fallback()
|
||||
await copyReady
|
||||
await json(route, { directory: createdWorkspace })
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}**`, async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
if (request.method() === "OPTIONS" && url.pathname === `/api/session/${sessionID}/move`)
|
||||
return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||
return json(route, { data: currentSession })
|
||||
if (request.method() !== "POST" || url.pathname !== `/api/session/${sessionID}/move`)
|
||||
return route.fallback()
|
||||
await moveReady
|
||||
currentSession.location.directory = createdWorkspace
|
||||
await route.fulfill({ status: 204, headers: cors })
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
|
||||
const copied = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
const moved = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/move`,
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
|
||||
await copied
|
||||
await expect(page.getByText("Creating workspace", { exact: true })).toBeVisible()
|
||||
releaseCopy()
|
||||
|
||||
const moveRequest = await moved
|
||||
expect(moveRequest.postDataJSON()).toEqual({ directory: createdWorkspace })
|
||||
await transport.send({
|
||||
id: "evt_workspace_created",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
location: { directory: root },
|
||||
data: {
|
||||
sessionID,
|
||||
location: { directory: createdWorkspace },
|
||||
subpath: "",
|
||||
},
|
||||
})
|
||||
releaseMove()
|
||||
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -16,6 +16,7 @@
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./regression/workspaces.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (/^\/api\/project\/[^/]+\/directories$/.test(path))
|
||||
return json(route, [
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git_worktree",
|
||||
})),
|
||||
])
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||
if (projectCopy && route.request().method() === "POST") {
|
||||
@@ -243,7 +251,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => !directory || session.directory === directory)
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
@@ -466,6 +477,7 @@ function currentPermission(value: unknown) {
|
||||
|
||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
const location = session.location && typeof session.location === "object" ? session.location : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
@@ -483,10 +495,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
},
|
||||
title: session.title ?? session.id,
|
||||
location: {
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
directory:
|
||||
"directory" in location && typeof location.directory === "string"
|
||||
? location.directory
|
||||
: typeof session.directory === "string"
|
||||
? session.directory
|
||||
: fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string"
|
||||
? { workspaceID: session.workspaceID }
|
||||
: "workspaceID" in location && typeof location.workspaceID === "string"
|
||||
? { workspaceID: location.workspaceID }
|
||||
: {}),
|
||||
},
|
||||
subpath: session.path,
|
||||
subpath: session.subpath ?? session.path,
|
||||
revert: session.revert,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
waitForConnection(options?: {
|
||||
after?: number
|
||||
timeout?: number
|
||||
path?: SseConnectionRecord["path"]
|
||||
}): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| {
|
||||
type: "send"
|
||||
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||
burst: boolean
|
||||
cuts?: number[]
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| {
|
||||
type: "end"
|
||||
mode: "close" | "disconnect" | "error"
|
||||
message?: string
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const current = (path?: SseConnectionRecord["path"]) =>
|
||||
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||
const connection = current(path)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
||||
if (input.type === "connections")
|
||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||
if (input.type === "acknowledgements") return acknowledgements
|
||||
if (input.type === "end") return end(input.mode, input.message)
|
||||
const connection = current()
|
||||
if (input.type === "end") return end(input.mode, input.message, input.path)
|
||||
const connection = current(input.type === "send" ? input.path : undefined)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
@@ -235,12 +251,15 @@ export async function installSseTransport<T>(
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
const connection = await page.waitForFunction(
|
||||
(after) => {
|
||||
({ after, path }) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
|
||||
return connections?.findLast(
|
||||
(connection) =>
|
||||
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||
)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ after: input.after ?? 0, path: input.path },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
let result: SseConnectionRecord | undefined
|
||||
@@ -252,8 +271,8 @@ export async function installSseTransport<T>(
|
||||
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||
return result
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
send(payload, eventOptions, path) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
@@ -291,8 +310,8 @@ export async function installSseTransport<T>(
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
error(message, path) {
|
||||
return command({ type: "end", mode: "error", message, path })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
|
||||
+10
-28
@@ -1,6 +1,7 @@
|
||||
import "@/index.css"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
@@ -51,10 +52,9 @@ import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { legacySessionServer, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { TargetSessionRoute } from "@/pages/session-lazy"
|
||||
import { Home } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -75,30 +75,6 @@ const DirectoryDraftRedirect = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
@@ -156,7 +132,13 @@ function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<I18nProvider
|
||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
||||
value={{
|
||||
locale: language.intl,
|
||||
layoutLocale: language.layoutLocale,
|
||||
t: language.t as UiI18n["t"],
|
||||
plural: language.plural,
|
||||
pluralForm: language.pluralForm,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</I18nProvider>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
.project-settings-v2-dialog [data-slot="dialog-container"] {
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
.project-settings-v2-dialog [data-slot="dialog-body"] {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-settings-v2 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-settings-v2-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-settings-v2-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.project-settings-v2-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.project-settings-v2-scroll {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-settings-page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-settings-page-header h2 {
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-page-header > span {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-extensions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
margin-top: 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: auto;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger"] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-settings-extension-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header > :last-child {
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
}
|
||||
|
||||
.project-settings-extension-link {
|
||||
color: var(--v2-text-text-accent) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-settings-extension-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-settings-extension-card {
|
||||
padding-inline: 12px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
.project-settings-extension-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.project-settings-extension-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.project-settings-extension-row-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-shared {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-settings-shared-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.project-settings-shared-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.project-settings-shared-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.project-settings-shared-count {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -1,156 +1,223 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { For, Show } from "solid-js"
|
||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||
import { SettingsServerDataScope } from "./settings-server-picker"
|
||||
import "./settings-v2/settings-v2.css"
|
||||
import "./dialog-edit-project-v2.css"
|
||||
|
||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
return (
|
||||
<SettingsServerDataScope server={props.server}>
|
||||
<SDKProvider directory={props.project.worktree}>
|
||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||
</SDKProvider>
|
||||
</SettingsServerDataScope>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
const projectName = () => displayName(props.project)
|
||||
const [tab, setTab] = createSignal("general")
|
||||
|
||||
const Footer = () => (
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<form onSubmit={model.submit} class="contents">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => {
|
||||
model.setIconInput(element)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
|
||||
<TabsV2
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="project-settings-v2"
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="project-settings-v2-nav">
|
||||
<TabsV2.Trigger value="general">
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
class="!size-4 shrink-0"
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="truncate">{projectName()}</span>
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="scripts">
|
||||
<Icon name="code" size="small" />
|
||||
{language.t("project.settings.scripts")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="extensions">
|
||||
<Icon name="extensions" size="small" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
<TabsV2.Content value="general" class="project-settings-v2-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||
<div class="project-settings-v2-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("dialog.project.edit.title")}</h2>
|
||||
<span>{language.t("project.settings.general.description")}</span>
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => model.setIconInput(element)}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={3}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||
<div class="project-settings-v2-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("project.settings.scripts")}</h2>
|
||||
<span>{language.t("project.settings.scripts.description")}</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={5}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
|
||||
<ProjectSettingsExtensions />
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
name: string
|
||||
location: string
|
||||
}
|
||||
|
||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
|
||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
<div class="project-settings-extension-card">{props.children as any}</div>
|
||||
)
|
||||
|
||||
const ExtensionRow: Component<{
|
||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||
name: string
|
||||
status?: string
|
||||
children?: unknown
|
||||
}> = (props) => (
|
||||
<div class="project-settings-extension-row">
|
||||
<div class="project-settings-extension-row-main">
|
||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||
</div>
|
||||
<Show when={props.status}>
|
||||
{(status) => (
|
||||
<span class="project-settings-extension-row-status">
|
||||
<span class="project-settings-extension-row-status-dot" />
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{props.children as any}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SharedSection: Component<{
|
||||
count: number
|
||||
children: unknown
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
return (
|
||||
<Show when={props.count > 0}>
|
||||
<div class="project-settings-shared">
|
||||
<button
|
||||
type="button"
|
||||
class="project-settings-shared-trigger"
|
||||
aria-expanded={open()}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
|
||||
<span>{language.t("project.settings.extensions.shared")}</span>
|
||||
<span class="project-settings-shared-count">{props.count}</span>
|
||||
</button>
|
||||
<Show when={open()}>
|
||||
<ExtensionCard>{props.children}</ExtensionCard>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProjectSettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const directorySDK = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sync = useSync()
|
||||
const toggleMcp = useMcpToggle()
|
||||
|
||||
const [serverMcp] = createResource(
|
||||
serverSDK,
|
||||
(sdk) =>
|
||||
sdk.api.mcp
|
||||
.list()
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const globalMcpNames = createMemo(() =>
|
||||
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||
)
|
||||
const projectMcpNames = createMemo(() => {
|
||||
const shared = new Set(globalMcpNames())
|
||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||
if (configured.length > 0) return configured.sort()
|
||||
return Object.keys(sync().data.mcp ?? {})
|
||||
.filter((name) => !shared.has(name))
|
||||
.sort()
|
||||
})
|
||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||
|
||||
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const [serverSkills] = createResource(
|
||||
serverSDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const [directorySkills] = createResource(
|
||||
directorySDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill
|
||||
.list({ location: { directory: sdk.directory } })
|
||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const projectSkills = createMemo(() => {
|
||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||
})
|
||||
|
||||
const mcpRows = (items: string[]) => (
|
||||
<For each={items}>
|
||||
{(name) => (
|
||||
<ExtensionRow icon="mcp" name={name}>
|
||||
<Switch
|
||||
checked={mcpEnabled(name)}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
hideLabel
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Switch>
|
||||
</ExtensionRow>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
|
||||
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
|
||||
|
||||
const skillRows = (items: SkillItem[]) => (
|
||||
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="project-settings-extensions">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("settings.tab.extensions")}</h2>
|
||||
<span>{language.t("project.settings.extensions.description")}</span>
|
||||
</div>
|
||||
|
||||
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
||||
<TabsV2.List>
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<Show when={projectMcpNames().length > 0}>
|
||||
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="plugins">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<Show when={projectPlugins().length > 0}>
|
||||
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="skills">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="lsps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||
</div>
|
||||
<Show when={sync().data.lsp.length > 0}>
|
||||
<ExtensionCard>
|
||||
<For each={sync().data.lsp}>
|
||||
{(item) => (
|
||||
<ExtensionRow
|
||||
icon="code"
|
||||
name={item.name || item.id}
|
||||
status={
|
||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ExtensionCard>
|
||||
</Show>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
variantControlVisible={!props.controller.model.loading}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sessionCreateInputs: Array<{
|
||||
type SessionCreateInput = {
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
}
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -22,11 +21,9 @@ const optimistic: Array<{
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const syncedDirectories: string[] = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
@@ -37,15 +34,29 @@ const switchedModels: Array<{
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let worktreeHung = false
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const draftServers: Record<string, string> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
@@ -73,21 +84,25 @@ const prompt = {
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
capture: (scope?: unknown, target?: unknown) => {
|
||||
promptCaptures.push({ scope, target })
|
||||
return prompt
|
||||
},
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
create: async (input: SessionCreateInput) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
const id = `session-${createdSessions.length}`
|
||||
sessionDirectories[id] = location
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
id,
|
||||
projectID: "project",
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
@@ -100,7 +115,7 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(directory)
|
||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||
promptInputs.push(input)
|
||||
return { data: undefined }
|
||||
},
|
||||
@@ -120,6 +135,19 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
||||
},
|
||||
},
|
||||
projectCopy: {
|
||||
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
worktreeCreates++
|
||||
if (worktreeHung)
|
||||
return new Promise<never>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||
})
|
||||
await createWorktreeGate
|
||||
if (worktreeFailure) throw worktreeFailure
|
||||
return { directory: worktreeDirectory }
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,9 +155,6 @@ const clientFor = (directory: string) => {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +170,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
@@ -162,20 +188,13 @@ beforeAll(async () => {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote(directory: string, sessionID: string) {
|
||||
promoted.push({ directory, sessionID })
|
||||
},
|
||||
promote: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
},
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
@@ -184,7 +203,10 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: () => ({ server: "project-server" }),
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
@@ -205,68 +227,70 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
}
|
||||
return () => sdk
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
useServerSync: () => () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -286,205 +310,159 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sessionCreateInputs.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
syncedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
sentShellDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
worktreeHung = false
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = makeSubmit()
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
await settle()
|
||||
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
},
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-b" },
|
||||
},
|
||||
])
|
||||
expect(sentShell).toEqual([
|
||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||
selected = "create"
|
||||
worktreeHung = true
|
||||
let resets = 0
|
||||
const submit = makeSubmit({
|
||||
onNewSessionWorktreeReset: () => resets++,
|
||||
worktreeRequestTimeoutMs: 1,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
expect(selected).toBe("create")
|
||||
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||
expect(resets).toBe(0)
|
||||
|
||||
worktreeHung = false
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(resets).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps auto-accept bound to the submission server", async () => {
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
let submitted = 0
|
||||
const submit = makeSubmit({
|
||||
onSubmit: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
permissionServer = "server-b"
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
test("switches the selected agent and model before prompting", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({
|
||||
id: "session-1",
|
||||
agent: "old-agent",
|
||||
model: { id: "old-model", providerID: "old-provider" },
|
||||
}),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
@@ -519,24 +497,12 @@ describe("prompt submit worktree selection", () => {
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
@@ -552,66 +518,20 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||
selected = "create"
|
||||
const submit = makeSubmit({
|
||||
mode: () => "shell",
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||
expect(sentShell[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "ls",
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,10 +17,12 @@ import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
@@ -28,9 +30,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
scope: ServerScope
|
||||
sessionID: string
|
||||
serverSync: ServerSync
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
const submitting = new Set<string>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
@@ -44,6 +50,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
scope: ServerScope
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
@@ -58,6 +65,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
@@ -248,6 +257,7 @@ type PromptSubmitInput = {
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
worktreeRequestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -263,7 +273,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
@@ -276,18 +287,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
const routeSessionID = params.id
|
||||
const owned =
|
||||
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||
? pending.get(pendingSubmission.key)
|
||||
: undefined
|
||||
const sessionID = routeSessionID ?? owned?.sessionID
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
const queued = pending.get(key)
|
||||
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||
const queued = owned ?? pending.get(key)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -319,9 +335,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: SessionInfo) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
@@ -353,6 +369,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
@@ -366,284 +383,369 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync()
|
||||
const submissionScope = submissionSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const handoff = layout.handoff
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
const onSubmit = input.onSubmit
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
const submissionKey = ScopedKey.from(
|
||||
submissionScope,
|
||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||
)
|
||||
if (submitting.has(submissionKey)) return
|
||||
submitting.add(submissionKey)
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await sdk()
|
||||
.api.projectCopy.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
try {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
submissionSDK.api.projectCopy.create(
|
||||
{
|
||||
projectID: submissionSync.data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.ready(submissionScope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
}
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||
if (!draftID) resetWorktree?.()
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
localSession.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
const startWorkspaceOperation = (messageID: string) => {
|
||||
if (!isNewSession) return
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
}
|
||||
if (worktreeSelection !== "create") return
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||
}
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
if (!worktree) return true
|
||||
if (worktree.status === "ready") {
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
if (worktree.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(worktree.message)
|
||||
}
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const key = pendingKey(submissionScope, session.id)
|
||||
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||
pending.set(key, {
|
||||
abort: controller,
|
||||
cleanup,
|
||||
scope: submissionScope,
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
serverSync: submissionServerSync,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
pending.delete(key)
|
||||
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(result.message)
|
||||
}
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
startWorkspaceOperation(eventID)
|
||||
void waitForWorktree(() => {
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
.then((ready) => {
|
||||
if (!ready) return
|
||||
return submissionSDK.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void waitForWorktree(() => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
restoreInput()
|
||||
})
|
||||
.then(async (ready) => {
|
||||
if (!ready) return
|
||||
return submissionSDK.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
void sendFollowupDraft({
|
||||
api: submissionSDK.api.session,
|
||||
scope: submissionScope,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: () => waitForWorktree(cleanup),
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(submissionScope, session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
} finally {
|
||||
submitting.delete(submissionKey)
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
session: () => input.info() ?? session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const [search, setSearch] = createSignal("")
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search().trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
pending = { type: "select", value }
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
if (open) {
|
||||
setSearch("")
|
||||
return
|
||||
}
|
||||
const action = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
if (action?.type === "viewAll") {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
@@ -41,87 +57,220 @@ export function PromptWorkspaceSelector(props: {
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-left">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("workspace.onboarding.description")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<PromptGitStatus branch={props.branch} />
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[200px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<Icon name="monitor" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("session.new.workspace.local")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.local.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("workspace.new")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.new.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={8}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
return
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<MenuV2.SubTrigger
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
)
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
}}
|
||||
value={search()}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||
return props.branch
|
||||
}
|
||||
|
||||
const icon = () => {
|
||||
if (props.noGit) return "monitor"
|
||||
if (props.from) return "branch-out"
|
||||
return "branch"
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon
|
||||
name={icon()}
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { workspaceDirectories } from "@/utils/workspace"
|
||||
import {
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
workspaceRequestWithTimeout,
|
||||
} from "@/utils/workspace-request"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible?: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||
gutter?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
children: JSX.Element
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||
const blocked = () =>
|
||||
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||
const workspaces = () =>
|
||||
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||
|
||||
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
WorkspaceOperation.fail(scope, sessionID)
|
||||
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
if (store.selected || blocked()) return
|
||||
const sdk = serverSDK()
|
||||
const sync = serverSync()
|
||||
const scope = sdk.scope
|
||||
const sessionID = props.sessionID
|
||||
const messageID = props.messageID
|
||||
const source = props.directory
|
||||
setStore("selected", selection)
|
||||
|
||||
try {
|
||||
const destination =
|
||||
selection === "create"
|
||||
? await createWorkspace(
|
||||
props.project,
|
||||
source,
|
||||
sessionID,
|
||||
messageID,
|
||||
sdk,
|
||||
(message) => fail(scope, sessionID, message),
|
||||
{
|
||||
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
},
|
||||
)
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||
await workspaceRequestWithTimeout(
|
||||
(signal) => sdk.api.session.move({ sessionID, directory: destination }, { signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
const session = await workspaceRequestWithTimeout(
|
||||
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
)
|
||||
if (!session || pathKey(session.location.directory) !== pathKey(destination))
|
||||
throw new Error(language.t("workspace.move.failed"))
|
||||
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||
sync.reindexSession(sessionID, source)
|
||||
} catch (error) {
|
||||
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||
} finally {
|
||||
setStore("selected", undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
modal={false}
|
||||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||
<Icon name="monitor" />
|
||||
{language.t("session.new.workspace.local")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</MenuV2.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Group>
|
||||
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
project: Project,
|
||||
source: string,
|
||||
sessionID: string,
|
||||
messageID: string | undefined,
|
||||
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||
fail: (message: string) => void,
|
||||
messages: { createFailed: string },
|
||||
) {
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", project.worktree, messageID)
|
||||
const created = await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
serverSDK.api.projectCopy.create(
|
||||
{
|
||||
projectID: project.id,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(source),
|
||||
location: { directory: source },
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
messages.createFailed,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.catch((error) => {
|
||||
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||
return undefined
|
||||
})
|
||||
if (!created?.directory) return
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||
Worktree.ready(serverSDK.scope, created.directory)
|
||||
return created.directory
|
||||
}
|
||||
@@ -451,7 +451,10 @@ function SettingsKeybindsV2View(props: {
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
|
||||
</div>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type ParentProps, Show } from "solid-js"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
|
||||
export function SettingsServerScope(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
return (
|
||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
||||
{(server) => <SettingsServerDataScope server={server}>{props.children}</SettingsServerDataScope>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
<ServerSDKProvider server={() => props.server}>
|
||||
<ServerSyncProvider server={() => props.server}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
|
||||
import "./settings-v2.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
title: "settings.general.row.uiFont.title",
|
||||
description: "settings.general.row.uiFont.description",
|
||||
font: "ui",
|
||||
input: "setUI",
|
||||
},
|
||||
code: {
|
||||
action: "settings-code-font",
|
||||
title: "settings.general.row.font.title",
|
||||
description: "settings.general.row.font.description",
|
||||
font: "code",
|
||||
input: "setCode",
|
||||
},
|
||||
terminal: {
|
||||
action: "settings-terminal-font",
|
||||
title: "settings.general.row.terminalFont.title",
|
||||
description: "settings.general.row.terminalFont.description",
|
||||
font: "terminal",
|
||||
input: "setTerminal",
|
||||
},
|
||||
} as const
|
||||
|
||||
const FontSetting: Component<{
|
||||
kind: "ui" | "code" | "terminal"
|
||||
fonts: AppearanceSettingsController["fonts"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => fontSettings[props.kind]
|
||||
return (
|
||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action={config().action}
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={props.fonts[config().font]().value}
|
||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||
placeholder={props.fonts[config().font]().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t(config().title)}
|
||||
style={{ "font-family": props.fonts[config().font]().family }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsAppearanceV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-color-scheme"
|
||||
options={schemeOptions}
|
||||
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) => {
|
||||
if (option === "system") return language.t("theme.scheme.system")
|
||||
if (option === "light") return language.t("theme.scheme.light")
|
||||
return language.t("theme.scheme.dark")
|
||||
}}
|
||||
onSelect={(option) => option && appearance.scheme.select(option)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</ExternalLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-theme"
|
||||
options={appearance.theme.options()}
|
||||
current={appearance.theme.current()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.name}
|
||||
onSelect={appearance.theme.select}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<FontSetting kind="ui" fonts={appearance.fonts} />
|
||||
<FontSetting kind="code" fonts={appearance.fonts} />
|
||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,23 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneralV2 } from "./general"
|
||||
import { SettingsAppearanceV2 } from "./appearance"
|
||||
import { SettingsKeybinds } from "../settings-keybinds"
|
||||
import { SettingsNotificationsV2 } from "./notifications"
|
||||
import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||
import { SettingsProjectsV2 } from "./projects"
|
||||
import { SettingsExtensionsV2 } from "./extensions"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
@@ -25,8 +33,13 @@ export const DialogSettings: Component<{
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const serverSync = useServerSync()
|
||||
const global = useGlobal()
|
||||
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
|
||||
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
const directory = createMemo(() => {
|
||||
const server = global.settings.server.selected()
|
||||
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
|
||||
const route = layout.route()
|
||||
if (route.type === "dir-new-sesssion") return route.dir
|
||||
if (route.type === "draft") {
|
||||
@@ -52,62 +65,96 @@ export const DialogSettings: Component<{
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.general")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.preferences")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="appearance">
|
||||
<Icon name="appearance" />
|
||||
{language.t("settings.general.section.appearance")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="notifications">
|
||||
<Icon name="notifications" />
|
||||
{language.t("settings.tab.notifications")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="projects">
|
||||
<Icon name="folder" />
|
||||
{language.t("settings.tab.projects")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="workspaces">
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("settings.tab.workspaces")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="extensions">
|
||||
<Icon name="extensions" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
||||
<SettingsAppearanceV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
||||
<SettingsNotificationsV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
<TabsV2.Content value="projects" class="settings-v2-panel">
|
||||
<SettingsProjectsV2 />
|
||||
</TabsV2.Content>
|
||||
<SettingsServerScope>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
||||
<SettingsExtensionsV2 />
|
||||
</TabsV2.Content>
|
||||
</SettingsServerScope>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Component, For, createMemo, createResource } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
|
||||
interface McpRowItem {
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface PluginRowItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SettingsExtensionsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const mcps = createMemo<McpRowItem[]>(() => {
|
||||
const configMcp = serverSync().data.config.mcp ?? {}
|
||||
return Object.entries(configMcp).map(([name, config]) => ({
|
||||
name,
|
||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||
}))
|
||||
})
|
||||
|
||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||
const before = serverSync().data.config.mcp ?? {}
|
||||
const config = before[item.name]
|
||||
if (typeof config !== "object" || config === null) return
|
||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||
serverSync().set("config", "mcp", next)
|
||||
void serverSync()
|
||||
.updateConfig({ mcp: next })
|
||||
.catch(() => serverSync().set("config", "mcp", before))
|
||||
}
|
||||
|
||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||
const raw = serverSync().data.config.plugin ?? []
|
||||
return raw.map((item) => {
|
||||
const name = typeof item === "string" ? item : item[0]
|
||||
return { name }
|
||||
})
|
||||
})
|
||||
|
||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||
initialValue: [],
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
|
||||
<TabsV2.List>
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
<div class="settings-v2-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={mcps()}>
|
||||
{(item) => (
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||
</div>
|
||||
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="plugins">
|
||||
<div class="settings-v2-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="skills">
|
||||
<div class="settings-v2-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<ExternalLink
|
||||
class="text-13-regular text-v2-text-accent hover:underline"
|
||||
href="https://opencode.ai/docs/skills/"
|
||||
>
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
@@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceDestinationSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.workspaces.default.title")}
|
||||
description={language.t("settings.workspaces.default.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
@@ -279,8 +307,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const updater = useUpdaterAction()
|
||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||
const shell = createShellSettingsController()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
const sounds = createSoundSettingsController()
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
@@ -298,9 +324,11 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
|
||||
<SettingsListV2>
|
||||
<LanguageSetting />
|
||||
|
||||
<WorkspaceDestinationSetting />
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
@@ -363,18 +391,6 @@ export const SettingsGeneralV2: Component<{
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
@@ -510,18 +526,19 @@ export const SettingsGeneralV2: Component<{
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.preferences.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection controller={appearance} />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection controller={sounds} />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useModels } from "@/context/models"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
@@ -53,7 +54,13 @@ export const SettingsModelsV2: Component = () => {
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Component } from "solid-js"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
|
||||
import "./settings-v2.css"
|
||||
|
||||
const soundSettings = {
|
||||
agent: {
|
||||
action: "settings-sounds-agent",
|
||||
title: "settings.general.sounds.agent.title",
|
||||
description: "settings.general.sounds.agent.description",
|
||||
},
|
||||
permissions: {
|
||||
action: "settings-sounds-permissions",
|
||||
title: "settings.general.sounds.permissions.title",
|
||||
description: "settings.general.sounds.permissions.description",
|
||||
},
|
||||
errors: {
|
||||
action: "settings-sounds-errors",
|
||||
title: "settings.general.sounds.errors.title",
|
||||
description: "settings.general.sounds.errors.description",
|
||||
},
|
||||
} as const
|
||||
|
||||
const SoundSetting: Component<{
|
||||
kind: "agent" | "permissions" | "errors"
|
||||
channel: SoundSettingsController["agent"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => soundSettings[props.kind]
|
||||
return (
|
||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action={config().action}
|
||||
options={soundOptions}
|
||||
current={props.channel.current()}
|
||||
value={(option) => option.id}
|
||||
label={(option) => language.t(option.label)}
|
||||
onHighlight={props.channel.highlight}
|
||||
onSelect={props.channel.select}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsNotificationsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const sounds = createSoundSettingsController()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.notifications.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
<SettingsListV2>
|
||||
<SoundSetting kind="agent" channel={sounds.agent} />
|
||||
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
||||
<SoundSetting kind="errors" channel={sounds.errors} />
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Show, createMemo, type Component } from "solid-js"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
|
||||
const allServers = { type: "all" } as const
|
||||
type ServerOption = ServerConnection.Any | typeof allServers
|
||||
|
||||
export const InlineServerSelect: Component<{
|
||||
all?: {
|
||||
label: string
|
||||
selected: () => boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
onServerSelect?: () => void
|
||||
}> = (props) => {
|
||||
const global = useGlobal()
|
||||
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
|
||||
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
|
||||
|
||||
return (
|
||||
<Show when={options().length > 1}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-server-select"
|
||||
options={options()}
|
||||
current={current()}
|
||||
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
|
||||
label={(server) =>
|
||||
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
|
||||
}
|
||||
optionDisabled={(server) =>
|
||||
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
|
||||
}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(server) => {
|
||||
if (!server) return
|
||||
if (server.type === "all") {
|
||||
props.all?.onSelect()
|
||||
return
|
||||
}
|
||||
global.settings.server.set(ServerConnection.key(server))
|
||||
props.onServerSelect?.()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const SettingsProjectsV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const [allServers, setAllServers] = createSignal(true)
|
||||
const selected = global.settings.server.selected
|
||||
const projects = createMemo(() => {
|
||||
const server = selected()
|
||||
if (!server) return []
|
||||
return global.ensureServerCtx(server).projects.list()
|
||||
})
|
||||
|
||||
type ProjectItem = ReturnType<typeof projects>[number]
|
||||
|
||||
const groups = createMemo(() =>
|
||||
global.servers
|
||||
.list()
|
||||
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
|
||||
.filter((group) => group.projects.length > 0),
|
||||
)
|
||||
|
||||
const openProjectSettings = (project: ProjectItem, server = selected()) => {
|
||||
if (!server) return
|
||||
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
|
||||
}
|
||||
|
||||
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
||||
const name = () => displayName(props.project)
|
||||
const color = () => getProjectAvatarVariant(props.project.icon?.color)
|
||||
return (
|
||||
<div
|
||||
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
||||
onClick={() => openProjectSettings(props.project, props.server)}
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openProjectSettings(props.project, props.server)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect
|
||||
all={{
|
||||
label: language.t("settings.projects.server.all"),
|
||||
selected: allServers,
|
||||
onSelect: () => setAllServers(true),
|
||||
}}
|
||||
onServerSelect={() => setAllServers(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show
|
||||
when={allServers()}
|
||||
fallback={
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<Show
|
||||
when={projects().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={selected()} keyed>
|
||||
{(server) => (
|
||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">
|
||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<For each={group.projects}>
|
||||
{(project) => <ProjectRow project={project} server={group.server} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
|
||||
@@ -42,13 +44,23 @@ export const SettingsProvidersV2: Component<{
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
void dialog.show(() => (
|
||||
<SettingsServerScope>
|
||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||
return providers.connected().filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" ||
|
||||
Object.values(provider.models).some((model) => {
|
||||
if (typeof model !== "object" || model === null || !("cost" in model)) return false
|
||||
const cost = model.cost
|
||||
return typeof cost === "object" && cost !== null && "input" in cost
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
@@ -92,29 +104,6 @@ export const SettingsProvidersV2: Component<{
|
||||
return true
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
await serverSdk()
|
||||
@@ -141,7 +130,13 @@ export const SettingsProvidersV2: Component<{
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-providers">
|
||||
@@ -244,7 +239,11 @@ export const SettingsProvidersV2: Component<{
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
dialog.show(() => (
|
||||
<SettingsServerScope>
|
||||
<DialogCustomProvider onBack={dialog.close} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
|
||||
@@ -53,7 +53,10 @@ export const SettingsServersV2: Component = () => {
|
||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||
>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
|
||||
</div>
|
||||
<AddServerMenu onAddServer={openAdd} />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-tab-title {
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
@@ -174,13 +182,13 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] {
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
padding-inline: 8px;
|
||||
@@ -684,6 +692,223 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||
font-weight: 610;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-count {
|
||||
font-size: 15px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-delete-all {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active,
|
||||
.settings-v2-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 4px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session:not(:last-child) {
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
padding: 0 20px 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar,
|
||||
.settings-v2-workspaces-main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
@@ -727,3 +952,24 @@
|
||||
line-height: 1;
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: 280px;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger"] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Project } from "@/types"
|
||||
import {
|
||||
containsDirectory,
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
mergeWorkspaceSessionInventory,
|
||||
removeWorkspacesSequentially,
|
||||
sessionsForWorkspace,
|
||||
type WorkspaceDeleteInspection,
|
||||
workspaceInventory,
|
||||
} from "@/utils/workspace"
|
||||
import { listAllSessions } from "@/utils/session"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type Workspace = {
|
||||
directory: string
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
||||
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||
const projectOptions = createMemo(() => [
|
||||
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||
])
|
||||
const selectedProject = createMemo(() =>
|
||||
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||
)
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
const sdk = serverSDK()
|
||||
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||
}
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
return mergeWorkspaceSessionInventory(
|
||||
fetched,
|
||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||
)
|
||||
}
|
||||
const sessionQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||
queryFn: () => loadSessions(),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const workspaceSessions = (workspace: Workspace) => {
|
||||
if (!sessionQuery.isSuccess) return []
|
||||
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
||||
}
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
return language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project: projectName(workspace.project),
|
||||
})
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
if (!updated) return undefined
|
||||
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||
}
|
||||
const sessionTime = (session: SessionInfo) => {
|
||||
if (!session.time.updated) return undefined
|
||||
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||
}
|
||||
|
||||
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||
const [working, branch, sessions] = await Promise.all([
|
||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||
loadSessions(context),
|
||||
])
|
||||
const result = inspectWorkspaceDeletion({
|
||||
workspace: workspace.directory,
|
||||
activeDirectory: context.activeDirectory,
|
||||
sessions,
|
||||
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||
})
|
||||
return { result, sessions }
|
||||
}
|
||||
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||
return language.t("workspace.status.clean")
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: inspectionMessage(result),
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.projectCopy
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force: allowDirty,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(
|
||||
workspace.directory,
|
||||
preflight.sessions.map((session) => session.id),
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.id === workspace.project.id)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
const releaseConfirmation = () => {
|
||||
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||
}
|
||||
const transact = async (task: () => Promise<void>) => {
|
||||
if (store.transaction !== "confirm") return
|
||||
setStore("transaction", "running")
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
} finally {
|
||||
setStore("transaction", undefined)
|
||||
}
|
||||
}
|
||||
const confirmDelete = (workspace: Workspace) => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const current = ++inspectionID
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspace
|
||||
workspace={workspace}
|
||||
scope={context.sdk.scope}
|
||||
inspectionID={current}
|
||||
inspect={() => inspect(workspace, context)}
|
||||
inspectionMessage={inspectionMessage}
|
||||
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||
}
|
||||
const confirmDeleteAll = () => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...filtered()]
|
||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteAllWorkspaces
|
||||
count={inventory.length}
|
||||
project={project}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||
<div class="settings-v2-workspaces-toolbar">
|
||||
<span class="settings-v2-workspaces-count">
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-v2-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={projectOptions()}
|
||||
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && setStore("project", option.id)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<MenuV2 placement="bottom-end" gutter={4}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-dots" size="small" />}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-v2-workspaces-delete-all">
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
<div class="settings-v2-workspaces-row">
|
||||
<div class="settings-v2-workspaces-row-header">
|
||||
<div class="settings-v2-workspaces-copy">
|
||||
<div class="settings-v2-workspaces-main">
|
||||
<TooltipV2
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
</div>
|
||||
<div class="settings-v2-workspaces-row-actions">
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<TooltipV2
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace.directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={linked().length > 0}>
|
||||
<div class="settings-v2-workspaces-sessions">
|
||||
<For each={linked()}>
|
||||
{(session) => (
|
||||
<div class="settings-v2-workspaces-session">
|
||||
<span>{session.title}</span>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspace(props: {
|
||||
workspace: Workspace
|
||||
scope: ServerScope
|
||||
inspectionID: number
|
||||
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
|
||||
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const status = useQuery(() => ({
|
||||
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||
queryFn: props.inspect,
|
||||
staleTime: 0,
|
||||
}))
|
||||
const description = () => {
|
||||
if (status.isPending) return language.t("workspace.status.checking")
|
||||
if (status.isError) return language.t("workspace.status.error")
|
||||
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||
}
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
<br />
|
||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
<br />
|
||||
{language.t("settings.workspaces.delete.warning")}
|
||||
<br />
|
||||
{description()}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={
|
||||
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
||||
}
|
||||
onClick={remove}
|
||||
>
|
||||
{language.t("workspace.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -106,16 +106,26 @@ describe("query keys", () => {
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const api = {
|
||||
list: async () => [
|
||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
directories: async ({ projectID }: { projectID: string }) => {
|
||||
calls.push(projectID)
|
||||
return [
|
||||
{ directory: `/${projectID}` },
|
||||
{ directory: `/${projectID}/copy`, strategy: "git_worktree" },
|
||||
]
|
||||
},
|
||||
} as unknown as ProjectApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([["/a/copy"], ["/b/copy"]])
|
||||
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
readonly directories: ServerApi["project"]["directories"]
|
||||
}
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
@@ -116,10 +117,16 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
api.list().then(async (projects) => {
|
||||
return (await Promise.all(
|
||||
projects.filter((project) => !!project?.id).map(async (project) => {
|
||||
const directories = await api.directories({ projectID: project.id })
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories.filter((item) => item.strategy !== undefined).map((item) => item.directory),
|
||||
})
|
||||
}),
|
||||
))
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
||||
import {
|
||||
pluralCategory,
|
||||
type UiI18nPluralLookupKey,
|
||||
type UiI18nPluralKey,
|
||||
type UiPluralCategory,
|
||||
} from "@opencode-ai/ui/context/i18n"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
@@ -28,11 +33,13 @@ function localeDirection(locale: Locale): Direction {
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type PluralKey =
|
||||
| UiI18nPluralKey
|
||||
| "session.question.pending"
|
||||
| "session.followupDock.summary"
|
||||
| "session.revertDock.summary"
|
||||
type AppI18nKey = Extract<keyof typeof en, string>
|
||||
type AppI18nPluralKey = {
|
||||
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||
}[AppI18nKey]
|
||||
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
@@ -189,18 +196,23 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
|
||||
key: TranslationKey<Key>,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
||||
const category = pluralCategory(intl(), count)
|
||||
const pluralForm = (
|
||||
key: PluralKey,
|
||||
category: UiPluralCategory,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||
const candidate = `${key}.${category}`
|
||||
const fallback = `${key}.other`
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||
}
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||
|
||||
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||
|
||||
@@ -231,6 +243,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
label,
|
||||
t,
|
||||
plural,
|
||||
pluralForm,
|
||||
setLocale(next: Locale) {
|
||||
setStore("locale", normalizeLocale(next))
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||
const current = settings.general.newLayoutDesigns()
|
||||
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||
: undefined
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
pick(scope, target).capture(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import {
|
||||
adaptServerEvent,
|
||||
applyWorkspaceOperationEvent,
|
||||
coalesceServerEvents,
|
||||
enqueueServerEvent,
|
||||
resumeStreamAfterPageShow,
|
||||
} from "./server-sdk"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
test("a moved event completes the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "current", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: adaptServerEvent({
|
||||
id: "moved-current",
|
||||
created: Date.now(),
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "current", seq: 1, version: 1 },
|
||||
data: { sessionID: "current", location: { directory: "/workspace" } },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>),
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "current")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -72,7 +95,7 @@ describe("current event buffering", () => {
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.tool.input.delta" }>)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
@@ -68,6 +69,16 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||
if (event.payload.current?.type !== "session.moved") return false
|
||||
WorkspaceOperation.complete(
|
||||
scope,
|
||||
event.payload.current.data.sessionID,
|
||||
event.payload.current.data.location.directory,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
@@ -151,7 +162,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
output.forEach((event) => {
|
||||
applyWorkspaceOperationEvent(scope, event)
|
||||
emitter.emit(event.directory, event.payload)
|
||||
})
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
|
||||
@@ -358,6 +358,23 @@ describe("server session", () => {
|
||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||
})
|
||||
|
||||
test("applies moved session locations without evicting cached state", () => {
|
||||
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
|
||||
const ctx = setup({ child: current })
|
||||
ctx.store.remember(current)
|
||||
|
||||
ctx.store.applyV2({
|
||||
id: "evt_moved",
|
||||
created: 2,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
location: current.location,
|
||||
data: { sessionID: "child", location: { directory: "/repo" }, subpath: "packages/app" },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||
|
||||
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
|
||||
})
|
||||
|
||||
test("loads session content through the server client", async () => {
|
||||
const ctx = setup({ root: session("root") })
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type MessageApi = ServerApi["message"]
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const historyMessagePageSize = 50
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -45,6 +45,12 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
|
||||
]
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||
if (scheduler) return scheduler.yield()
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -241,7 +247,13 @@ export function createServerSession(
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
|
||||
const projected = projectMessageSource(message)
|
||||
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: SessionInfo) => {
|
||||
@@ -288,17 +300,19 @@ export function createServerSession(
|
||||
return session
|
||||
}
|
||||
|
||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||
const cached = data.info[sessionID]
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi.get({ sessionID })
|
||||
const request = sessionApi.get({ sessionID }, { signal: options?.signal })
|
||||
const resolved = request.then((result) => {
|
||||
if (options?.signal?.aborted) return result
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
if (options?.signal) return resolved
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
@@ -530,6 +544,7 @@ export function createServerSession(
|
||||
if (!response.data.length) break
|
||||
}
|
||||
const response = pages.at(-1)!
|
||||
await yieldToMain()
|
||||
const source = pages.flatMap((page) => page.data).toReversed()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
@@ -1300,6 +1315,7 @@ export function createServerSession(
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexProjectedMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
@@ -1333,6 +1349,9 @@ export function createServerSession(
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== input.messageID),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
|
||||
@@ -5,13 +5,21 @@ import type {
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
OpenCodeEvent,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import {
|
||||
captureSessionMove,
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
seedActiveSessionStatuses,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
@@ -101,6 +109,30 @@ describe("active session query", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session move normalization", () => {
|
||||
test("captures and applies current moves from the source placement", () => {
|
||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||
session.remember(sessionAt("/source"))
|
||||
const current = {
|
||||
id: "event-current-move",
|
||||
created: 10,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 },
|
||||
location: { directory: "/source" },
|
||||
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>
|
||||
const event = adaptServerEvent(current)
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
})
|
||||
session.applyV2(current)
|
||||
session.apply(event)
|
||||
expect(session.get("session")?.location.directory).toBe("/destination")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
@@ -171,6 +203,18 @@ function sessionInfo(id: string) {
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
function sessionAt(directory: string): SessionInfo {
|
||||
return {
|
||||
id: "session",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
title: "Session",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
@@ -55,6 +55,28 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export function captureSessionMove(
|
||||
event: ServerEvent,
|
||||
get: (sessionID: string) => { location: { directory: string } } | undefined,
|
||||
) {
|
||||
if (event.current?.type !== "session.moved") return
|
||||
return {
|
||||
sessionID: event.current.data.sessionID,
|
||||
from: get(event.current.data.sessionID)?.location.directory,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type = event.current?.type ?? event.type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.moved" ||
|
||||
type === "session.renamed" ||
|
||||
type === "session.forked"
|
||||
)
|
||||
}
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
@@ -458,15 +480,51 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
}
|
||||
|
||||
const reindexSession = (sessionID: string, from?: string) => {
|
||||
const next = session.get(sessionID)
|
||||
if (!next) return
|
||||
indexSession(next)
|
||||
if (!from) return
|
||||
const source = children.children[directoryKey(from)]
|
||||
if (!source) return
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.moved",
|
||||
properties: {
|
||||
sessionID,
|
||||
projectID: next.projectID,
|
||||
location: next.location,
|
||||
subpath: next.subpath,
|
||||
},
|
||||
},
|
||||
directory: from,
|
||||
store: source[0],
|
||||
setStore: source[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
const moved = captureSessionMove(event, session.get)
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||
if (shouldRefreshWorkspaceSessions(event)) {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||
})
|
||||
}
|
||||
if (event.current?.type === "session.created")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -519,10 +577,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -639,6 +693,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
reindexSession,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||
export type WorkspaceLastUsed = "local" | "workspace"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -44,6 +48,10 @@ export interface Settings {
|
||||
permissions: {
|
||||
autoApprove: boolean
|
||||
}
|
||||
workspaces: {
|
||||
defaultDestination: WorkspaceDefaultDestination
|
||||
lastUsed: Record<string, WorkspaceLastUsed>
|
||||
}
|
||||
notifications: NotificationSettings
|
||||
sounds: SoundSettings
|
||||
}
|
||||
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
|
||||
permissions: {
|
||||
autoApprove: false,
|
||||
},
|
||||
workspaces: {
|
||||
defaultDestination: "last-used",
|
||||
lastUsed: {},
|
||||
},
|
||||
notifications: {
|
||||
agent: true,
|
||||
permissions: true,
|
||||
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
defaultDestination: withFallback(
|
||||
() => store.workspaces?.defaultDestination,
|
||||
defaultSettings.workspaces.defaultDestination,
|
||||
),
|
||||
setDefaultDestination(value: WorkspaceDefaultDestination) {
|
||||
setStore("workspaces", (current) => ({
|
||||
...defaultSettings.workspaces,
|
||||
...current,
|
||||
defaultDestination: value,
|
||||
}))
|
||||
},
|
||||
lastUsed(scope: ServerScope, projectID: string) {
|
||||
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
|
||||
},
|
||||
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
|
||||
setStore("workspaces", (current) => ({
|
||||
...defaultSettings.workspaces,
|
||||
...current,
|
||||
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
|
||||
}))
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
|
||||
@@ -177,6 +177,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const actions = {
|
||||
active() {
|
||||
if (location.pathname === "/") return
|
||||
const key = recentKey()
|
||||
return store.find((tab) => tabKey(tab) === key)
|
||||
},
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
@@ -351,8 +356,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
console.log({ tab, session, current })
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
setInfo(key, next)
|
||||
},
|
||||
select: navigateTab,
|
||||
|
||||
@@ -892,14 +892,46 @@ export const dict = {
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.preferences": "Preferences",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
|
||||
"settings.shortcuts.description": "Customize shortcuts for common actions",
|
||||
"settings.servers.description": "Manage server connections",
|
||||
"settings.projects.title": "Projects",
|
||||
"settings.projects.description": "Manage project settings on this server",
|
||||
"settings.projects.empty": "No projects found",
|
||||
"settings.projects.server.all": "All servers",
|
||||
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||
"settings.extensions.description": "Manage extensions available on this server",
|
||||
"settings.extensions.tab.mcps": "MCPs",
|
||||
"settings.extensions.tab.skills": "Skills",
|
||||
"settings.extensions.availableAll": "Available to all projects",
|
||||
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||
"settings.extensions.addSkills": "How to add skills",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
"project.settings.extensions.description": "View extensions available to this project",
|
||||
"project.settings.extensions.tab.lsps": "LSPs",
|
||||
"project.settings.extensions.added": "Added to this project",
|
||||
"project.settings.extensions.shared": "Shared with all projects",
|
||||
"project.settings.extensions.lsp.detected": "Detected language servers",
|
||||
"project.settings.extensions.lsp.description": "Auto-detected from file types",
|
||||
"project.settings.extensions.setupRequired": "Setup required",
|
||||
|
||||
"settings.general.section.appearance": "Appearance",
|
||||
"settings.general.section.general": "General",
|
||||
"settings.general.section.advanced": "Advanced",
|
||||
"settings.general.section.notifications": "System notifications",
|
||||
"settings.general.section.notifications": "Desktop notifications",
|
||||
"settings.general.section.updates": "Updates",
|
||||
"settings.general.section.sounds": "Sound effects",
|
||||
"settings.general.section.feed": "Feed",
|
||||
@@ -1060,7 +1092,7 @@ export const dict = {
|
||||
"settings.shortcuts.group.prompt": "Prompt",
|
||||
|
||||
"settings.providers.title": "Providers",
|
||||
"settings.providers.description": "Provider settings will be configurable here.",
|
||||
"settings.providers.description": "Connect and manage model providers",
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
@@ -1071,7 +1103,7 @@ export const dict = {
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Model settings will be configurable here.",
|
||||
"settings.models.description": "Choose which models appear in model picker",
|
||||
"settings.agents.title": "Agents",
|
||||
"settings.agents.description": "Agent settings will be configurable here.",
|
||||
"settings.commands.title": "Commands",
|
||||
@@ -1123,6 +1155,46 @@ export const dict = {
|
||||
"session.delete.button": "Delete session",
|
||||
|
||||
"workspace.new": "New workspace",
|
||||
"common.viewAll": "View all",
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||
"settings.tab.workspaces": "Workspaces",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
"settings.workspaces.empty": "No workspaces",
|
||||
"settings.workspaces.count.one": "{{count}} workspace",
|
||||
"settings.workspaces.count.other": "{{count}} workspaces",
|
||||
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||
"settings.workspaces.lastActiveSession": "Last active session",
|
||||
"settings.workspaces.deleteAll": "Delete all workspaces",
|
||||
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
|
||||
"settings.workspaces.delete.warning":
|
||||
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.deleteAll.warning":
|
||||
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
"settings.workspaces.default.local": "Local directory",
|
||||
"settings.workspaces.default.new": "New workspace",
|
||||
"workspace.move.title": "Move to workspace",
|
||||
"workspace.move.menu.title": "Move session to",
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating workspace",
|
||||
"workspace.lifecycle.created": "Workspace created",
|
||||
"workspace.lifecycle.starting": "Starting session",
|
||||
"workspace.onboarding.title": "Isolate sessions with workspaces",
|
||||
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||
"workspace.lifecycle.moving": "Moving to workspace",
|
||||
"workspace.lifecycle.set": "Workspace set",
|
||||
"session.summary.title": "Session details",
|
||||
"session.summary.noBranch": "No branch",
|
||||
"session.summary.basedOn": "Based on {{branch}}",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Failed to create workspace",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { desktopNativePluralCategories } from "./desktop-native"
|
||||
|
||||
const appLocales = [
|
||||
"ar",
|
||||
@@ -65,15 +64,54 @@ const appLocales = [
|
||||
"uz",
|
||||
] as const
|
||||
const desktopLocales = appLocales
|
||||
const pluralCategories = new Map(
|
||||
appLocales.map(
|
||||
(locale) =>
|
||||
[
|
||||
locale,
|
||||
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
const pluralCategories = new Set(["zero", "one", "two", "few", "many", "other"])
|
||||
const appFallbackKeys = new Set([
|
||||
"dialog.provider.custom.label",
|
||||
"dialog.model.unpaid.viewMoreProviders",
|
||||
"session.header.reveal.finder",
|
||||
"session.header.reveal.fileExplorer",
|
||||
"session.header.reveal.containingFolder",
|
||||
"command.session.export",
|
||||
"command.session.export.description",
|
||||
"context.export.session",
|
||||
"toast.session.export.success.title",
|
||||
"toast.session.export.success.description",
|
||||
"toast.session.export.failed.title",
|
||||
"toast.session.export.failed.description",
|
||||
"common.export",
|
||||
"settings.tab.preferences",
|
||||
"settings.tab.notifications",
|
||||
"settings.tab.projects",
|
||||
"settings.tab.extensions",
|
||||
"settings.preferences.description",
|
||||
"settings.appearance.description",
|
||||
"settings.notifications.description",
|
||||
"settings.shortcuts.description",
|
||||
"settings.servers.description",
|
||||
"settings.projects.title",
|
||||
"settings.projects.description",
|
||||
"settings.projects.empty",
|
||||
"settings.projects.server.all",
|
||||
"settings.mcps.description",
|
||||
"settings.extensions.description",
|
||||
"settings.extensions.tab.mcps",
|
||||
"settings.extensions.tab.skills",
|
||||
"settings.extensions.availableAll",
|
||||
"settings.extensions.manageConfig",
|
||||
"settings.extensions.addSkills",
|
||||
"settings.general.section.general",
|
||||
"dialog.server.authenticate.title",
|
||||
"project.settings.general.description",
|
||||
"project.settings.scripts",
|
||||
"project.settings.scripts.description",
|
||||
"project.settings.extensions.description",
|
||||
"project.settings.extensions.tab.lsps",
|
||||
"project.settings.extensions.added",
|
||||
"project.settings.extensions.shared",
|
||||
"project.settings.extensions.lsp.detected",
|
||||
"project.settings.extensions.lsp.description",
|
||||
"project.settings.extensions.setupRequired",
|
||||
])
|
||||
|
||||
const domains = [
|
||||
{
|
||||
@@ -97,23 +135,22 @@ const domains = [
|
||||
] as const
|
||||
|
||||
describe("i18n parity", () => {
|
||||
test("non-English locales have every English key and required plural variants", async () => {
|
||||
test("non-English locales contain only English keys and their plural variants", async () => {
|
||||
for (const domain of domains) {
|
||||
const source = await dictionary(domain.source)
|
||||
const families = new Set(pluralFamilies(source))
|
||||
for (const locale of domain.locales) {
|
||||
const target = await dictionary(domain.target(locale))
|
||||
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
|
||||
const missing = Object.keys(source).filter(
|
||||
(key) => !Object.hasOwn(target, key) && (domain.name !== "app" || !appFallbackKeys.has(key)),
|
||||
)
|
||||
const extra = Object.keys(target)
|
||||
.filter((key) => !Object.hasOwn(source, key))
|
||||
.filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families))
|
||||
.sort()
|
||||
const expected = pluralFamilies(source)
|
||||
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
|
||||
.sort()
|
||||
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
||||
expect({ domain: domain.name, locale, extra }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
missing: [],
|
||||
extra: expected,
|
||||
extra: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -127,11 +164,11 @@ describe("i18n parity", () => {
|
||||
const mismatched = Object.keys(source).filter(
|
||||
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||
)
|
||||
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
||||
)
|
||||
const pluralMismatched = Object.keys(target).filter((key) => {
|
||||
const family = pluralFamily(key)
|
||||
if (!family || !Object.hasOwn(source, `${family}.other`)) return false
|
||||
return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join()
|
||||
})
|
||||
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
@@ -169,38 +206,6 @@ describe("i18n parity", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("i18n plural parity", () => {
|
||||
test("locale-specific categories exist and preserve count placeholders", async () => {
|
||||
for (const domain of domains.slice(0, 2)) {
|
||||
const source = await dictionary(domain.source)
|
||||
const families = pluralFamilies(source)
|
||||
for (const locale of domain.locales) {
|
||||
const target = await dictionary(domain.target(locale))
|
||||
const missing = families.flatMap((key) =>
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => !Object.hasOwn(target, variant)),
|
||||
)
|
||||
const mismatched = families.flatMap((key) =>
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter(
|
||||
(variant) =>
|
||||
Object.hasOwn(target, variant) &&
|
||||
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
|
||||
),
|
||||
)
|
||||
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
missing: [],
|
||||
mismatched: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function dictionary(file: string) {
|
||||
const module: unknown = await import(file)
|
||||
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
||||
@@ -220,11 +225,17 @@ function placeholders(value: string) {
|
||||
|
||||
function pluralFamilies(dictionary: Record<string, string>) {
|
||||
return Object.keys(dictionary)
|
||||
.filter(
|
||||
(key) =>
|
||||
key.endsWith(".one") &&
|
||||
dictionary[key].includes("{{count}}") &&
|
||||
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
||||
)
|
||||
.filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`))
|
||||
.map((key) => key.slice(0, -4))
|
||||
}
|
||||
|
||||
function pluralFamily(key: string) {
|
||||
const split = key.lastIndexOf(".")
|
||||
if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return
|
||||
return key.slice(0, split)
|
||||
}
|
||||
|
||||
function isPluralVariant(key: string, families: Set<string>) {
|
||||
const family = pluralFamily(key)
|
||||
return family !== undefined && families.has(family)
|
||||
}
|
||||
|
||||
@@ -327,4 +327,9 @@
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
|
||||
height: 24px;
|
||||
padding-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,4 @@ export {
|
||||
} from "./wsl/types"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
||||
export { preloadSessionRoute } from "./pages/session-lazy"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createHomeController } from "./home/home-controller"
|
||||
import { createHomeProjectsController } from "./home/home-projects-controller"
|
||||
import { HomeUtilityNav } from "./home/home-projects-view"
|
||||
@@ -7,8 +8,23 @@ import { createHomeScrollController } from "./home/home-scroll-controller"
|
||||
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||
import { HomeSessions } from "./home/home-sessions"
|
||||
import { preloadSessionRoute } from "./session-lazy"
|
||||
|
||||
export function Home() {
|
||||
onMount(() => {
|
||||
let idle: number | undefined
|
||||
const timer = setTimeout(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
idle = requestIdleCallback(() => void preloadSessionRoute(), { timeout: 3_000 })
|
||||
return
|
||||
}
|
||||
void preloadSessionRoute()
|
||||
}, 1_500)
|
||||
onCleanup(() => {
|
||||
clearTimeout(timer)
|
||||
if (idle !== undefined) cancelIdleCallback(idle)
|
||||
})
|
||||
})
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { compareSessionTime, displayName, errorMessage, projectForSession } from
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
@@ -208,6 +209,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return
|
||||
const [, setStore] = ctx.sync.child(session.location.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createEffect, createResource } from "solid-js"
|
||||
import { useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
||||
@@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
|
||||
export default function NewSessionPage() {
|
||||
const settings = useSettings()
|
||||
const rightMount = useTitlebarRightMount()
|
||||
const workspace = createNewSessionWorkspaceController()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const draftTab = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||
)
|
||||
const workspace = createNewSessionWorkspaceController({
|
||||
selected: () => draftTab()?.worktree,
|
||||
setSelected: (worktree) => {
|
||||
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const draft = createNewSessionDraftController({
|
||||
worktree: workspace.selection.value,
|
||||
resetWorktree: workspace.selection.reset,
|
||||
onSubmit: workspace.selection.remember,
|
||||
})
|
||||
const project = createPromptProjectController({
|
||||
controls: draft.project.controls,
|
||||
|
||||
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
|
||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
||||
export function createNewSessionDraftController(workspace: {
|
||||
worktree: () => string
|
||||
resetWorktree: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
|
||||
return workspace.worktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||
onSubmit: comments.clear,
|
||||
onSubmit: () => {
|
||||
workspace.onSubmit()
|
||||
comments.clear()
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
}) {
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
createStore({ used: false }),
|
||||
)
|
||||
const select = (value: string) => {
|
||||
props.workspace.selection.set(value)
|
||||
if (value !== "main") setOnboarding("used", true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
@@ -41,7 +50,7 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<PromptInputV2Composer controller={props.input} />
|
||||
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onChange={props.workspace.selection.set}
|
||||
onboarding={onboardingReady() && !onboarding.used}
|
||||
onChange={select}
|
||||
onDone={props.input.restoreFocus}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -137,7 +148,7 @@ function ProviderTip() {
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
@@ -152,7 +163,7 @@ function ProviderTip() {
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
} from "@/utils/workspace"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
fallback?: string
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
||||
return "main"
|
||||
return input.fallback ?? "main"
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
@@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController() {
|
||||
export function createNewSessionWorkspaceController(input: {
|
||||
selected: () => string | undefined
|
||||
setSelected: (worktree: string | undefined) => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const [worktree, setWorktree] = createSignal<string>()
|
||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const settings = useSettings()
|
||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||
const selected = createMemo(() => {
|
||||
const project = sync().project
|
||||
const worktree = input.selected()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = sync().project
|
||||
if (!project) return "main"
|
||||
return workspaceDefaultSelection(
|
||||
settings.workspaces.defaultDestination(),
|
||||
settings.workspaces.lastUsed(serverSDK().scope, project.id),
|
||||
)
|
||||
})
|
||||
const value = createMemo(() =>
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: worktree(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
@@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
|
||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
||||
}),
|
||||
)
|
||||
const remember = (worktree = value()) => {
|
||||
const project = sync().project
|
||||
if (!project) return
|
||||
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
|
||||
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
value,
|
||||
reset: () => setWorktree(),
|
||||
set: (worktree: string) =>
|
||||
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
||||
workspace: createMemo(() => {
|
||||
const project = sync().project
|
||||
const current = value()
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => input.setSelected(undefined),
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => sync().project?.sandboxes ?? [],
|
||||
workspaces: () => {
|
||||
const project = sync().project
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: () => sync().project?.vcs === "git",
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { lazy } from "solid-js"
|
||||
|
||||
export const TargetSessionRoute = lazy(() => import("./target-session-route"))
|
||||
export const preloadSessionRoute = TargetSessionRoute.preload
|
||||
@@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
@@ -101,6 +102,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
|
||||
@@ -521,6 +523,9 @@ export default function Page() {
|
||||
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
|
||||
}
|
||||
|
||||
const workspaceSession = createMemo(() =>
|
||||
isWorkspaceDirectory(sync().project, controller.data.info()?.location.directory ?? sdk().directory),
|
||||
)
|
||||
const timeline = createTimelineModel({ session: controller })
|
||||
const historyLoading = timeline.history.loading
|
||||
const historyMore = timeline.history.more
|
||||
@@ -575,6 +580,7 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
...sessionViewState(),
|
||||
newSessionWorktree: "main",
|
||||
sessionDetailsOpen: false,
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
@@ -677,6 +683,19 @@ export default function Page() {
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const sessionDetailsQuery = createQuery(() => ({
|
||||
queryKey: [...vcsKey(), "git"] as const,
|
||||
enabled: store.sessionDetailsOpen && sync().project?.vcs === "git",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load session details diff", { error })
|
||||
return []
|
||||
}),
|
||||
}))
|
||||
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
createEffect(
|
||||
on(
|
||||
@@ -1671,6 +1690,8 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
@@ -1684,8 +1705,20 @@ export default function Page() {
|
||||
return followup.edit[id]
|
||||
})
|
||||
|
||||
const workspaceMoveEligible = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
if (!id) return false
|
||||
return canMoveSessionToWorkspace({
|
||||
queued: followup.items[id]?.length ?? 0,
|
||||
failed: !!followup.failed[id],
|
||||
paused: !!followup.paused[id],
|
||||
editing: !!followup.edit[id],
|
||||
})
|
||||
})
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const owner = controller.ownership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
@@ -1695,6 +1728,7 @@ export default function Page() {
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
scope: serverSDK().scope,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
session: () => sync().session.get(input.sessionID),
|
||||
@@ -1763,6 +1797,7 @@ export default function Page() {
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (workspaceOperationPending(sessionID)) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
@@ -1802,6 +1837,7 @@ export default function Page() {
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
@@ -1824,6 +1860,7 @@ export default function Page() {
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = controller.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
@@ -1853,7 +1890,10 @@ export default function Page() {
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const reverting = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id))
|
||||
})
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
@@ -1913,6 +1953,7 @@ export default function Page() {
|
||||
if (controller.data.isChild()) return
|
||||
if (composer.blocked()) return
|
||||
if (controller.data.working()) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
void sendFollowup(sessionID, item.id)
|
||||
})
|
||||
@@ -2020,7 +2061,7 @@ export default function Page() {
|
||||
>
|
||||
{hasReview()
|
||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||
: language.t("session.review.change.other")}
|
||||
: language.plural("session.review.change", 0)}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
@@ -2088,6 +2129,9 @@ export default function Page() {
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
diffs={sessionDetailsDiffs}
|
||||
workspaceMoveEligible={workspaceMoveEligible()}
|
||||
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
@@ -2215,7 +2259,13 @@ export default function Page() {
|
||||
setFollowup("paused", id, true)
|
||||
},
|
||||
})
|
||||
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
|
||||
return (
|
||||
<PromptInputV2Composer
|
||||
controller={promptInputController}
|
||||
borderUnderlay
|
||||
accentSubmit={workspaceSession()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function createPromptProjectControls() {
|
||||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -782,10 +782,7 @@ export function SessionSidePanel(props: {
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
{props.reviewCount()} {language.plural("session.review.change", props.reviewCount())}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
@@ -41,7 +43,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
|
||||
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
@@ -49,11 +51,21 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
@@ -108,7 +120,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const maxFiles = 10
|
||||
const [state, setState] = createStore({
|
||||
@@ -136,6 +148,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
{props.action}
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
@@ -190,6 +203,179 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceLocationLoader() {
|
||||
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
|
||||
return (
|
||||
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
|
||||
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
|
||||
<For each={dots}>
|
||||
{(position, index) => (
|
||||
<span
|
||||
class={`absolute size-1 bg-current ${position} animate-pulse`}
|
||||
style={{ "animation-delay": `${index() * -180}ms` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const inline = () => props.variant === "inline"
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"group/workspace-move relative shrink-0": true,
|
||||
"ml-auto h-5 w-[167px]": inline(),
|
||||
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
|
||||
!inline(),
|
||||
invisible: props.dismissed,
|
||||
}}
|
||||
>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.eligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement={inline() ? "bottom-end" : "left-start"}
|
||||
gutter={inline() ? 4 : -22}
|
||||
contentClass={inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
|
||||
inline()
|
||||
? "right-0 top-1/2"
|
||||
: "hover-reveal right-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
|
||||
}`}
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDismiss()
|
||||
}}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
project: Project
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
messageID?: string
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
|
||||
const branch = () => props.branch ?? props.baseBranch
|
||||
const row =
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class="w-[280px]">
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement="left-start"
|
||||
gutter={-22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
|
||||
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span>{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||
onClick={props.onReview}
|
||||
>
|
||||
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChanges changes={props.diffs} />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
dismissed={props.moveDismissed}
|
||||
onDismiss={props.onMoveDismiss}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
const fileComponent = useFileComponent()
|
||||
const view = normalize(props.diff)
|
||||
@@ -218,6 +404,9 @@ type MessageTimelineProps = {
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
diffs: Accessor<{ additions: number; deletions: number }[]>
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
@@ -240,6 +429,11 @@ function MessageTimelineView(
|
||||
) {
|
||||
let touchGesture: number | undefined
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommand()
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
@@ -254,18 +448,87 @@ function MessageTimelineView(
|
||||
const parentID = props.data.parentID
|
||||
const parentTitle = props.data.parentTitle
|
||||
const childTitle = props.data.childTitle
|
||||
const showHeader = props.data.showHeader
|
||||
const getMsgParts = props.data.parts
|
||||
const getMsgPart = props.data.part
|
||||
const projection = props.data.projection
|
||||
const sessionDirectory = createMemo(
|
||||
() => props.session.data.info()?.location.directory ?? sdk().directory,
|
||||
)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||
const setSummary = (open: boolean) => {
|
||||
setSummaryOpen(open)
|
||||
props.onSummaryOpenChange(open)
|
||||
}
|
||||
const sessionDiffs = createMemo(props.diffs)
|
||||
createEffect(
|
||||
on(sessionID, () => {
|
||||
setSummary(false)
|
||||
setWorkspaceSuggestionDismissed(false)
|
||||
}),
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const workspaceOperation = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return WorkspaceOperation.get(serverSDK().scope, id)
|
||||
})
|
||||
const lifecycleTitle = createMemo(() => {
|
||||
const operation = workspaceOperation()
|
||||
if (operation?.status === "pending") {
|
||||
return {
|
||||
kind: "pending" as const,
|
||||
text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"),
|
||||
}
|
||||
}
|
||||
if (operation?.type === "create" && !props.data.titleValue())
|
||||
return { kind: "created" as const, text: language.t("workspace.lifecycle.created") }
|
||||
if (!props.data.titleValue())
|
||||
return { kind: "starting" as const, text: language.t("workspace.lifecycle.starting") }
|
||||
return
|
||||
})
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||
const messageByID = projection.messageByID
|
||||
const messageLastRowIndex = projection.messageLastRowIndex
|
||||
const messageRowIndex = projection.messageRowIndex
|
||||
const timelineRowByKey = projection.rowByKey
|
||||
const timelineRows = projection.rows
|
||||
const timelineRows = createMemo(() => {
|
||||
const rows = projection.rows()
|
||||
const operation = workspaceOperation()
|
||||
const userMessageID = operation?.messageID ?? props.userMessages.at(-1)?.id
|
||||
if (!operation || !userMessageID) return rows
|
||||
const index = rows.findIndex((row) => row._tag === "UserMessage" && row.userMessageID === userMessageID)
|
||||
if (index < 0) return rows
|
||||
return [
|
||||
...rows.slice(0, index + 1),
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID,
|
||||
notice: { type: "operation", operation },
|
||||
}),
|
||||
...rows.slice(index + 1),
|
||||
]
|
||||
})
|
||||
const timelineRowByKey = createMemo(
|
||||
() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)),
|
||||
)
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const messageLastRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
if ("userMessageID" in row) result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
@@ -749,7 +1012,7 @@ function MessageTimelineView(
|
||||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class={`w-full pb-2 ${turnPadding()}`}>
|
||||
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
@@ -800,7 +1063,7 @@ function MessageTimelineView(
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message
|
||||
message={message()}
|
||||
@@ -816,11 +1079,55 @@ function MessageTimelineView(
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "WorkspaceLifecycle": {
|
||||
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
|
||||
const operation = () => workspaceRow().notice.operation
|
||||
const pending = () => operation().status === "pending"
|
||||
const status = () => {
|
||||
if (operation().status === "failed") return language.t("workspace.move.failed")
|
||||
if (operation().type === "create")
|
||||
return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created")
|
||||
return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set")
|
||||
}
|
||||
const directory = () => getFilename(operation().directory)
|
||||
return (
|
||||
<TimelineRowFrame row={workspaceRow}>
|
||||
<div class={`w-full ${turnPadding()}`} aria-live="polite">
|
||||
<div class="flex h-7 items-center py-1 text-[13px] font-[440] leading-none tracking-[-0.04px]">
|
||||
<Show
|
||||
when={!pending()}
|
||||
fallback={
|
||||
<div class="flex items-center gap-1.5">
|
||||
<TextShimmer text={status()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center gap-1.5": true,
|
||||
"text-v2-state-fg-danger": operation().status === "failed",
|
||||
}}
|
||||
>
|
||||
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>{status()}</span>
|
||||
<Show when={operation().status !== "failed"}>
|
||||
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
|
||||
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-accent" />
|
||||
<Show when={directory()}>
|
||||
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
label={language.t(
|
||||
@@ -836,7 +1143,7 @@ function MessageTimelineView(
|
||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||
@@ -851,7 +1158,7 @@ function MessageTimelineView(
|
||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={props.data.showReasoningSummaries()}
|
||||
@@ -864,7 +1171,7 @@ function MessageTimelineView(
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
@@ -872,10 +1179,35 @@ function MessageTimelineView(
|
||||
}
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
const canMove = () =>
|
||||
props.data.newLayoutDesigns() &&
|
||||
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||
!workspaceSession() &&
|
||||
props.workspaceMoveEligible &&
|
||||
sync().project?.vcs === "git" &&
|
||||
sessionStatus().type === "idle"
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineDiffSummaryRow
|
||||
diffs={diffSummaryRow().diffs}
|
||||
action={
|
||||
<Show when={canMove() && sync().project}>
|
||||
{(project) => (
|
||||
<WorkspaceMoveAction
|
||||
variant="inline"
|
||||
eligible={props.workspaceMoveEligible}
|
||||
sessionID={sessionID()!}
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
messageID={diffSummaryRow().userMessageID}
|
||||
dismissed={workspaceSuggestionDismissed()}
|
||||
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
@@ -884,7 +1216,7 @@ function MessageTimelineView(
|
||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
</Card>
|
||||
@@ -966,7 +1298,7 @@ function MessageTimelineView(
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
@@ -1061,6 +1393,39 @@ function MessageTimelineView(
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show when={props.data.newLayoutDesigns()}>
|
||||
<Show
|
||||
when={workspaceOperation()?.status !== "pending"}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<WorkspaceLocationLoader />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<IconV2 name="monitor" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
|
||||
>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1078,56 +1443,71 @@ function MessageTimelineView(
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
props.data.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||
<Show
|
||||
when={!lifecycleTitle()}
|
||||
fallback={
|
||||
<span
|
||||
class="px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px]"
|
||||
classList={{ "text-v2-text-text-base": lifecycleTitle()?.kind === "created" }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Show when={lifecycleTitle()?.kind !== "created"} fallback={lifecycleTitle()?.text}>
|
||||
<TextShimmer text={lifecycleTitle()!.text} />
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
props.data.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1145,6 +1525,47 @@ function MessageTimelineView(
|
||||
placement="bottom"
|
||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={props.data.newLayoutDesigns() && !parentID() && sync().project}>
|
||||
{(project) => (
|
||||
<KobaltePopover
|
||||
open={summaryOpen()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onOpenChange={setSummary}
|
||||
>
|
||||
<KobaltePopover.Trigger
|
||||
as={IconButtonV2}
|
||||
icon={<IconV2 name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={sync().data.vcs?.branch}
|
||||
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
messageID={props.userMessages.at(-1)?.id}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
command.trigger("review.toggle")
|
||||
}}
|
||||
/>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={props.data.newLayoutDesigns()}
|
||||
@@ -1210,12 +1631,21 @@ function MessageTimelineView(
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void props.action.export(id)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void props.action.export(id)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
{/* TODO: Need a V2 session archive API. */}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => {
|
||||
if (workspaceOperationPending(id)) return
|
||||
props.action.showDelete(id)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
@@ -1280,12 +1710,21 @@ function MessageTimelineView(
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void props.action.export(id)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void props.action.export(id)}
|
||||
>
|
||||
{language.t("common.export")}...
|
||||
</MenuV2.Item>
|
||||
{/* TODO: Need a V2 session archive API. */}
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => {
|
||||
if (workspaceOperationPending(id)) return
|
||||
props.action.showDelete(id)
|
||||
}}
|
||||
>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Window } from "happy-dom"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -18,6 +18,7 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
@@ -40,24 +41,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
targetWindow.document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([])
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, unrelated)
|
||||
mutations.remove(unrelated)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -204,7 +205,33 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { insertAfterUserMessage, reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
@@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => {
|
||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
|
||||
test("inserts lifecycle extensions immediately after the user message", () => {
|
||||
const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
|
||||
const lifecycle = new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: "user-1",
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(insertAfterUserMessage(rows, [lifecycle]).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"WorkspaceLifecycle",
|
||||
"DiffSummary",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
inlineComments: Accessor<boolean>
|
||||
extensionRevision?: Accessor<unknown>
|
||||
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[]
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
@@ -29,8 +31,9 @@ export function createTimelineProjection(input: {
|
||||
})
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
const projection = createMemo(() => {
|
||||
const extension = input.extensionRevision?.()
|
||||
return Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
@@ -38,8 +41,9 @@ export function createTimelineProjection(input: {
|
||||
input.status().type,
|
||||
input.inlineComments(),
|
||||
input.userMessages(),
|
||||
),
|
||||
)
|
||||
input.extensionRevision && extension === undefined ? undefined : input.afterUser,
|
||||
)
|
||||
})
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
|
||||
@@ -3,6 +3,12 @@ import { TimelineRow } from "./timeline-row"
|
||||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
export function insertAfterUserMessage(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
|
||||
const index = rows.findIndex((row) => row._tag === "UserMessage")
|
||||
rows.splice(index + 1, 0, ...extensions)
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
|
||||
@@ -13,6 +13,14 @@ mock.module("@opencode-ai/session-ui/message-part", () => ({
|
||||
}))
|
||||
|
||||
const { Timeline, TimelineRow } = await import("./rows")
|
||||
const lifecycle = (userMessageID: string) =>
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID,
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "create", status: "complete", directory: "/workspace", messageID: userMessageID },
|
||||
},
|
||||
})
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
test("derives turns and tagged rows from chronological current messages", () => {
|
||||
@@ -47,6 +55,7 @@ describe("current session timeline rows", () => {
|
||||
"busy",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => (message.id === "msg_3" ? [lifecycle(message.id)] : []),
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
@@ -55,6 +64,7 @@ describe("current session timeline rows", () => {
|
||||
"assistant-part:msg_1:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"workspace-lifecycle:msg_3:operation",
|
||||
"assistant-part:msg_3:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
@@ -83,11 +93,13 @@ describe("current session timeline rows", () => {
|
||||
"idle",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => [lifecycle(message.id)],
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_shell",
|
||||
"workspace-lifecycle:msg_shell:operation",
|
||||
"assistant-part:msg_shell:msg_shell:tool",
|
||||
])
|
||||
})
|
||||
@@ -157,6 +169,7 @@ describe("current session timeline rows", () => {
|
||||
"busy",
|
||||
true,
|
||||
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
|
||||
(message) => (message.id === optimistic.id ? [lifecycle(message.id)] : []),
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe(optimistic.id)
|
||||
@@ -164,6 +177,7 @@ describe("current session timeline rows", () => {
|
||||
"user-message:msg_z",
|
||||
"turn-gap:msg_a",
|
||||
"user-message:msg_a",
|
||||
"workspace-lifecycle:msg_a:operation",
|
||||
"thinking:msg_a",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
import { compareMessages } from "@/utils/session-message"
|
||||
import { insertAfterUserMessage } from "./row-reconciliation"
|
||||
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
@@ -28,6 +29,10 @@ export type TimelineRowMap = {
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
WorkspaceLifecycle: {
|
||||
userMessageID: string
|
||||
notice: TimelineRow.WorkspaceLifecycle["notice"]
|
||||
}
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
@@ -41,6 +46,7 @@ export namespace Timeline {
|
||||
status: SessionStatus["type"],
|
||||
inlineComments: boolean,
|
||||
projectedUserMessages: UserMessage[],
|
||||
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[],
|
||||
) {
|
||||
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||
@@ -83,8 +89,8 @@ export namespace Timeline {
|
||||
const activeMessageID = turns.at(-1)?.user.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
rows: turns.flatMap((turn, index) => {
|
||||
const rows = constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.assistants,
|
||||
@@ -93,8 +99,10 @@ export namespace Timeline {
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!afterUser) return rows
|
||||
return insertAfterUserMessage(rows, afterUser(turn.user))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
import type { WorkspaceOperationState } from "@/utils/workspace-operation"
|
||||
|
||||
export type SummaryDiff = FileDiffInfo
|
||||
|
||||
@@ -39,6 +40,10 @@ export namespace TimelineRow {
|
||||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
|
||||
userMessageID: string
|
||||
notice: { type: "operation"; operation: WorkspaceOperationState }
|
||||
}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
@@ -50,6 +55,7 @@ export namespace TimelineRow {
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| WorkspaceLifecycle
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
@@ -71,6 +77,8 @@ export namespace TimelineRow {
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
case "WorkspaceLifecycle":
|
||||
return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { UserMessage } from "@/types"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import type { SessionController } from "./session-controller"
|
||||
|
||||
type SessionCommandSource = {
|
||||
@@ -54,7 +53,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const navigate = useNavigate()
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = actions.session.ownership.capture()
|
||||
@@ -73,6 +71,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const showAllFiles = () => {
|
||||
@@ -291,6 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const undo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
@@ -321,6 +322,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const redo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = sdk().api.session
|
||||
const messages = actions.session.history.userMessages()
|
||||
@@ -355,11 +357,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const compact = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
await sdk().api.session.compact({ sessionID })
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
@@ -415,7 +421,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
actions.session.history.visibleUserMessages().length === 0 ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -423,7 +432,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
!actions.session.data.info()?.revert?.messageID ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -431,7 +443,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
actions.session.history.visibleUserMessages().length === 0 ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -439,7 +454,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
actions.session.history.visibleUserMessages().length === 0 ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: fork,
|
||||
}),
|
||||
sessionCommand({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { TargetSessionRouteContent } from "./session"
|
||||
|
||||
export default function TargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const connection = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={connection}>
|
||||
<ServerSyncProvider server={connection}>
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerScope } from "./server-scope"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
|
||||
|
||||
test("workspace moves require settled followup state", () => {
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
|
||||
expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
|
||||
})
|
||||
|
||||
describe("WorkspaceOperation", () => {
|
||||
test("settles only the matching pending operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session", "/other")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session", "/workspace")
|
||||
WorkspaceOperation.fail(ServerScope.local, "session")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export type WorkspaceOperationType = "create" | "move"
|
||||
export type WorkspaceOperationState = {
|
||||
type: WorkspaceOperationType
|
||||
status: "pending" | "complete" | "failed"
|
||||
directory: string
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
export function canMoveSessionToWorkspace(input: {
|
||||
queued: number
|
||||
failed: boolean
|
||||
paused: boolean
|
||||
editing: boolean
|
||||
}) {
|
||||
return input.queued === 0 && !input.failed && !input.paused && !input.editing
|
||||
}
|
||||
|
||||
const state = new Map<string, WorkspaceOperationState>()
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
|
||||
if (!state.has(key(scope, sessionID)) && state.size >= 100) {
|
||||
const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
|
||||
if (terminal) state.delete(terminal)
|
||||
}
|
||||
state.set(key(scope, sessionID), value)
|
||||
setVersion((current) => current + 1)
|
||||
}
|
||||
export const WorkspaceOperation = {
|
||||
get(scope: ServerScope, sessionID: string) {
|
||||
version()
|
||||
return state.get(key(scope, sessionID))
|
||||
},
|
||||
start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
|
||||
write(scope, sessionID, { type, directory, messageID, status: "pending" })
|
||||
},
|
||||
complete(scope: ServerScope, sessionID: string, directory?: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current) return
|
||||
if (directory && pathKey(directory) !== pathKey(current.directory)) return
|
||||
write(scope, sessionID, { ...current, status: "complete" })
|
||||
},
|
||||
fail(scope: ServerScope, sessionID: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current || current.status === "complete") return
|
||||
write(scope, sessionID, { ...current, status: "failed" })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function workspaceRequestWithTimeout<T>(
|
||||
request: (signal: AbortSignal) => Promise<T>,
|
||||
message: string,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const controller = new AbortController()
|
||||
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer.id = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
})
|
||||
return Promise.race([request(controller.signal), timeout])
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) throw new Error(message)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer.id !== undefined) clearTimeout(timer.id)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
mergeWorkspaceSessionInventory,
|
||||
sessionsForWorkspace,
|
||||
workspaceInventory,
|
||||
} from "./workspace"
|
||||
|
||||
describe("isWorkspaceDirectory", () => {
|
||||
const project = {
|
||||
worktree: "C:\\repo\\",
|
||||
sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
|
||||
}
|
||||
|
||||
test("distinguishes managed workspaces from the local repository", () => {
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
|
||||
expect(
|
||||
isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"),
|
||||
).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
|
||||
expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isWorkspaceSelection", () => {
|
||||
const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
|
||||
|
||||
test("accepts local, new, and managed workspace selections", () => {
|
||||
expect(isWorkspaceSelection(project, "main")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "create")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
|
||||
expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("groups and filters workspace inventory by project", () => {
|
||||
const inventory = workspaceInventory([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] },
|
||||
{ id: "b", worktree: "/b", sandboxes: ["/b/one"] },
|
||||
])
|
||||
|
||||
expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([
|
||||
["a", "/a/one"],
|
||||
["a", "/a/two"],
|
||||
["b", "/b/one"],
|
||||
])
|
||||
expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"])
|
||||
expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory)
|
||||
})
|
||||
|
||||
test("blocks unsafe workspace deletion", () => {
|
||||
const session = (directory: string) =>
|
||||
({ location: { directory }, time: { created: 1, updated: 1 } }) as SessionInfo
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
activeDirectory: "/workspace/app",
|
||||
sessions: [],
|
||||
status: "dirty",
|
||||
}),
|
||||
).toBe("active")
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
sessions: [session("/workspace/packages/app")],
|
||||
status: "dirty",
|
||||
}),
|
||||
).toBe("linked")
|
||||
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty")
|
||||
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe")
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
sessions: [
|
||||
{ location: { directory: "/workspace" }, time: { created: 1, updated: 1, archived: 2 } } as SessionInfo,
|
||||
],
|
||||
status: "clean",
|
||||
}),
|
||||
).toBe("safe")
|
||||
})
|
||||
|
||||
test("groups nested non-archived workspace sessions by latest activity", () => {
|
||||
const session = (id: string, directory: string, updated: number, archived?: number) =>
|
||||
({ id, location: { directory }, time: { created: 1, updated, archived } }) as SessionInfo
|
||||
const sessions = sessionsForWorkspace(
|
||||
[
|
||||
session("old", "/workspace", 2),
|
||||
session("nested", "/workspace/packages/app", 3),
|
||||
session("archived", "/workspace", 4, 5),
|
||||
session("other", "/other", 6),
|
||||
],
|
||||
"/workspace",
|
||||
)
|
||||
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
|
||||
})
|
||||
|
||||
test("merges workspace placement by freshness with authoritative server ties", () => {
|
||||
const session = (directory: string, updated: number) =>
|
||||
({ id: "session", location: { directory }, time: { created: 1, updated } }) as SessionInfo
|
||||
|
||||
expect(
|
||||
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.location.directory,
|
||||
).toBe("/destination")
|
||||
expect(
|
||||
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.location.directory,
|
||||
).toBe("/destination")
|
||||
expect(
|
||||
mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.location.directory,
|
||||
).toBe("/source")
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
|
||||
|
||||
export function workspaceDirectories(project: WorkspaceProject) {
|
||||
return (project.sandboxes ?? []).filter(
|
||||
(directory) => !containsDirectory(project.worktree, directory) || !containsDirectory(directory, project.worktree),
|
||||
)
|
||||
}
|
||||
|
||||
export function workspaceInventory<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
|
||||
return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project })))
|
||||
}
|
||||
|
||||
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
|
||||
workspaces: readonly T[],
|
||||
project: string,
|
||||
) {
|
||||
if (project === "all") return [...workspaces]
|
||||
return workspaces.filter((workspace) => workspace.project.id === project)
|
||||
}
|
||||
|
||||
export function sessionsForWorkspace(sessions: readonly SessionInfo[], workspace: string) {
|
||||
return sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.filter((session) => containsDirectory(workspace, session.location.directory))
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
export function mergeWorkspaceSessionInventory(server: readonly SessionInfo[], cached: readonly SessionInfo[]) {
|
||||
const sessions = new Map(server.map((session) => [session.id, session]))
|
||||
cached.forEach((session) => {
|
||||
const current = sessions.get(session.id)
|
||||
if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session)
|
||||
})
|
||||
return [...sessions.values()]
|
||||
}
|
||||
|
||||
export function removeWorkspacesSequentially<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
|
||||
return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve())
|
||||
}
|
||||
|
||||
export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty"
|
||||
|
||||
export function inspectWorkspaceDeletion(input: {
|
||||
workspace: string
|
||||
activeDirectory?: string
|
||||
sessions: readonly SessionInfo[]
|
||||
status: "clean" | "dirty"
|
||||
}): WorkspaceDeleteInspection {
|
||||
if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active"
|
||||
if (
|
||||
input.sessions.some(
|
||||
(session) =>
|
||||
session.time.archived === undefined && containsDirectory(input.workspace, session.location.directory),
|
||||
)
|
||||
)
|
||||
return "linked"
|
||||
if (input.status === "dirty") return "dirty"
|
||||
return "safe"
|
||||
}
|
||||
|
||||
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||
if (!project || (containsDirectory(project.worktree, directory) && containsDirectory(directory, project.worktree)))
|
||||
return false
|
||||
return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory))
|
||||
}
|
||||
|
||||
export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||
if (!project) return false
|
||||
return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory))
|
||||
}
|
||||
|
||||
export function containsDirectory(parent: string, child: string) {
|
||||
const normalize = (value: string) => {
|
||||
const key = pathKey(value)
|
||||
return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key
|
||||
}
|
||||
const root = normalize(parent)
|
||||
const target = normalize(child)
|
||||
return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)
|
||||
}
|
||||
|
||||
export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
|
||||
if (selection === "main" || selection === "create") return true
|
||||
if (!project) return false
|
||||
if (containsDirectory(project.worktree, selection) && containsDirectory(selection, project.worktree)) return true
|
||||
return isWorkspaceDirectory(project, selection)
|
||||
}
|
||||
|
||||
export function workspaceDefaultSelection(
|
||||
setting: WorkspaceDefaultDestination,
|
||||
lastUsed: WorkspaceLastUsed | undefined,
|
||||
) {
|
||||
if (setting === "local") return "main"
|
||||
if (setting === "new") return "create"
|
||||
return lastUsed === "workspace" ? "create" : "main"
|
||||
}
|
||||
@@ -42,8 +42,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Config } from "../../config"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
@@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
config: {
|
||||
update: (update) => runServicePromise(config.update(update)),
|
||||
},
|
||||
paths: { home: global.home, state: global.state, log: global.log },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -129,13 +128,9 @@ export async function usingInteractiveStdin<T>(
|
||||
export function createMiniHost(input: {
|
||||
terminal: InteractiveStdin
|
||||
directory: string
|
||||
paths?: { home: string; state: string; log: string }
|
||||
paths: { home: string; state: string; log: string }
|
||||
}): MiniHost {
|
||||
const paths = input.paths ?? {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
log: Global.Path.log,
|
||||
}
|
||||
const paths = input.paths
|
||||
const diagnostics = {
|
||||
pid: process.pid,
|
||||
cwd: input.directory,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
|
||||
demo?: boolean
|
||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||
config?: MiniFrontendInput["config"]
|
||||
paths: { home: string; state: string; log: string }
|
||||
}
|
||||
|
||||
type Model = MiniFrontendInput["model"]
|
||||
@@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) {
|
||||
}))
|
||||
const frontend = await frontendTask
|
||||
return frontend.runMiniFrontend({
|
||||
host: createMiniHost({ terminal, directory }),
|
||||
host: createMiniHost({ terminal, directory, paths: input.paths }),
|
||||
sdk,
|
||||
directory,
|
||||
target: resolveTarget,
|
||||
|
||||
@@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
|
||||
@@ -11,7 +11,9 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
expect(await fixture.spawn().close()).toBe(0)
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
expect(await acp.close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type PromptBase64 = string
|
||||
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
||||
])
|
||||
).map((module) => module.default)
|
||||
export const migrations = [
|
||||
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
JSONValue,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
LanguageModel,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
TransportReason,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
@@ -506,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
|
||||
case "text":
|
||||
case "error":
|
||||
return { type: "text" as const, value: messageValue(result.value) }
|
||||
case "content":
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: result.value.map((item) => {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text }
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
const image = item.mime.toLowerCase().startsWith("image/")
|
||||
if (data !== undefined)
|
||||
return image
|
||||
? { type: "image-data" as const, data, mediaType: item.mime }
|
||||
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
|
||||
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
|
||||
}),
|
||||
}
|
||||
case "json":
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
|
||||
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
||||
@@ -723,7 +740,9 @@ function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof AIError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
: APICallError.isInstance(error)
|
||||
? apiCallErrorReason(error)
|
||||
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
|
||||
return new AIError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
@@ -731,4 +750,57 @@ function llmError(method: string, error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function apiCallErrorReason(error: APICallError) {
|
||||
const details = providerErrorDetails(error)
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: details.message,
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
code: details.code,
|
||||
responseHeaders: error.responseHeaders,
|
||||
responseBody: error.responseBody,
|
||||
})
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||
return new TransportReason({
|
||||
message: reason.message,
|
||||
kind: error.name,
|
||||
url: error.url,
|
||||
http: "http" in reason ? reason.http : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
|
||||
const ProviderErrorDetail = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(ProviderErrorCode),
|
||||
})
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
...ProviderErrorDetail.fields,
|
||||
error: Schema.optionalKey(ProviderErrorDetail),
|
||||
})
|
||||
const decodeProviderError = Schema.decodeUnknownOption(
|
||||
Schema.Union([ProviderErrorBody, Schema.fromJsonString(ProviderErrorBody)]),
|
||||
)
|
||||
|
||||
function unknownErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.trim() === "" ? "Provider request failed" : message
|
||||
}
|
||||
|
||||
function providerErrorDetails(error: APICallError) {
|
||||
const data = Option.getOrUndefined(decodeProviderError(error.data))
|
||||
const body = Option.getOrUndefined(decodeProviderError(error.responseBody))
|
||||
const details = [data?.error, data, body?.error, body]
|
||||
const message = details.map((detail) => detail?.message).find((value) => value?.trim())
|
||||
const value = details.map((detail) => detail?.code).find((value) => value !== undefined)
|
||||
const code = value === undefined ? undefined : String(value)
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return {
|
||||
code,
|
||||
message:
|
||||
error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`)),
|
||||
}
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
@@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
@@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
location,
|
||||
processes,
|
||||
shell: options,
|
||||
bin: global.bin,
|
||||
})
|
||||
|
||||
const prompt = (yield* mcp.prompts()).find(
|
||||
@@ -164,6 +167,7 @@ function evaluateTemplate(
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
|
||||
const shell = ShellSelect.preferred(
|
||||
Config.latest(yield* services.config.entries(), "shell"),
|
||||
services.shell,
|
||||
services.bin,
|
||||
)
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
@@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,15 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
|
||||
const configs = yield* wellknown
|
||||
.resolve(entry, variables)
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
|
||||
Effect.as([] as const),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* Effect.forEach(configs, (config) =>
|
||||
ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
export * as ConfigInstructionPlugin from "./instruction"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery"
|
||||
import { Instructions } from "../../instructions/index"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
| { readonly type: "unavailable" }
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const project = discovery.project && FSUtil.contains(stop, start)
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
const candidates = [
|
||||
globalFile,
|
||||
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
const file = yield* read(globalFile)
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
|
||||
if (!project) return []
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
|
||||
)
|
||||
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
|
||||
if (files.some((file) => file === undefined)) return Instructions.unavailable
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
|
||||
Effect.as(Instructions.unavailable),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* discovery.transform((draft) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
draft.unavailable()
|
||||
return
|
||||
}
|
||||
for (const file of loaded.current.files) draft.add(file)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
|
||||
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export * as SkillFile from "./skill-file"
|
||||
|
||||
import path from "path"
|
||||
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
|
||||
|
||||
export type ParseResult =
|
||||
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = Reflect.get(metadata, key)
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parse(directory: string, filepath: string, content: string): ParseResult {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
|
||||
const decoded = decodeFrontmatter(markdown.data)
|
||||
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
|
||||
const frontmatter = decoded.success
|
||||
const id =
|
||||
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
|
||||
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
return {
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(frontmatter.name ?? id),
|
||||
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
|
||||
...(slash === undefined ? {} : { slash }),
|
||||
...(autoinvoke === undefined ? {} : { autoinvoke }),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,191 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location"
|
||||
import { SkillDiscovery } from "../../skill/discovery"
|
||||
import { SkillFile } from "./skill-file"
|
||||
|
||||
type Source = Skill.DirectorySource | Skill.UrlSource
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const watcher = yield* Watcher.Service
|
||||
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
|
||||
entries: yield* config.entries(),
|
||||
skills: [],
|
||||
}
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe({ path: target, type })
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{ onlyIfMissing: true, startImmediately: true },
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
|
||||
"ConfigSkillPlugin.watchDirectory",
|
||||
)(function* (directory: string) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) yield* watch(target, "file")
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const sources = () => {
|
||||
const result: Source[] = []
|
||||
const add = (source: Source) => {
|
||||
if (result.some((item) => Skill.Source.equals(item, source))) return
|
||||
result.push(source)
|
||||
}
|
||||
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
|
||||
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of [...claude, ...agents]) {
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
}
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
add(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
add(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
|
||||
const directories =
|
||||
source.type === "directory"
|
||||
? [source.path]
|
||||
: yield* discovery.pull(source.url).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load skill source", {
|
||||
source: Skill.Source.key(source),
|
||||
cause,
|
||||
}).pipe(Effect.as([] as AbsolutePath[])),
|
||||
),
|
||||
)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const skills: Skill.Info[] = []
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const parsed = SkillFile.parse(directory, filepath, content)
|
||||
if (parsed._tag === "Skipped") {
|
||||
yield* Effect.logDebug("skill file skipped", {
|
||||
filepath,
|
||||
reason: parsed.reason,
|
||||
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
skills.push(parsed.skill)
|
||||
}
|
||||
}
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Skill.Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return skills
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
for (const skill of loaded.skills) draft.add(skill)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(refresh()),
|
||||
Effect.andThen(ctx.skill.reload()),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export * as ConfigPluginSource from "./source"
|
||||
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
|
||||
readonly changes: () => Stream.Stream<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(fs, location, entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
return operations
|
||||
}),
|
||||
changes: () =>
|
||||
Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
Stream.fromPubSub(configuredChanges),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
|
||||
})
|
||||
|
||||
export const empty = makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
operations: () => Effect.succeed([]),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
location: Location.Interface,
|
||||
entries: readonly Entry[],
|
||||
) {
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
@@ -40,16 +40,19 @@ const databaseLayer = Layer.effect(
|
||||
)
|
||||
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.suspend(() => {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(Global.Path.data, filename))
|
||||
})
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
export const node = configured({ path: ":memory:" })
|
||||
|
||||
+84
-45
@@ -1,47 +1,86 @@
|
||||
import type { DatabaseMigration } from "./migration"
|
||||
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||
import m01 from "./migration/20260211171708_add_project_commands"
|
||||
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||
import m03 from "./migration/20260225215848_workspace"
|
||||
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||
import m09 from "./migration/20260323234822_events"
|
||||
import m10 from "./migration/20260410174513_workspace-name"
|
||||
import m11 from "./migration/20260413175956_chief_energizer"
|
||||
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||
import m14 from "./migration/20260428004200_add_session_path"
|
||||
import m15 from "./migration/20260501142318_next_venus"
|
||||
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||
import m18 from "./migration/20260510033149_session_usage"
|
||||
import m19 from "./migration/20260511000411_data_migration_state"
|
||||
import m20 from "./migration/20260511173437_session-metadata"
|
||||
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||
import m24 from "./migration/20260602182828_add_project_directories"
|
||||
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||
import m32 from "./migration/20260611035744_credential"
|
||||
import m33 from "./migration/20260611192811_lush_chimera"
|
||||
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
||||
import("./migration/20260211171708_add_project_commands"),
|
||||
import("./migration/20260213144116_wakeful_the_professor"),
|
||||
import("./migration/20260225215848_workspace"),
|
||||
import("./migration/20260227213759_add_session_workspace_id"),
|
||||
import("./migration/20260228203230_blue_harpoon"),
|
||||
import("./migration/20260303231226_add_workspace_fields"),
|
||||
import("./migration/20260309230000_move_org_to_state"),
|
||||
import("./migration/20260312043431_session_message_cursor"),
|
||||
import("./migration/20260323234822_events"),
|
||||
import("./migration/20260410174513_workspace-name"),
|
||||
import("./migration/20260413175956_chief_energizer"),
|
||||
import("./migration/20260423070820_add_icon_url_override"),
|
||||
import("./migration/20260427172553_slow_nightmare"),
|
||||
import("./migration/20260428004200_add_session_path"),
|
||||
import("./migration/20260501142318_next_venus"),
|
||||
import("./migration/20260504145000_add_sync_owner"),
|
||||
import("./migration/20260507164347_add_workspace_time"),
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
export const migrations = [
|
||||
m00,
|
||||
m01,
|
||||
m02,
|
||||
m03,
|
||||
m04,
|
||||
m05,
|
||||
m06,
|
||||
m07,
|
||||
m08,
|
||||
m09,
|
||||
m10,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m15,
|
||||
m16,
|
||||
m17,
|
||||
m18,
|
||||
m19,
|
||||
m20,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m25,
|
||||
m26,
|
||||
m27,
|
||||
m28,
|
||||
m29,
|
||||
m30,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m35,
|
||||
m36,
|
||||
m37,
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
@@ -13,7 +14,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
|
||||
}
|
||||
|
||||
export function apply(db: Database) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
@@ -33,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources"
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260805200742_import_legacy_credentials",
|
||||
up(tx) {
|
||||
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
|
||||
return Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -41,9 +45,9 @@ export default migration
|
||||
|
||||
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const file = Bun.file(filepath)
|
||||
if (!(yield* Effect.promise(() => file.exists()))) return
|
||||
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
|
||||
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
|
||||
if (content === undefined) return
|
||||
const input = Option.getOrUndefined(decodeJson(content))
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
|
||||
}
|
||||
|
||||
@@ -467,10 +467,11 @@ function updateProgress(progress: Progress) {
|
||||
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
@@ -478,7 +479,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
@@ -492,7 +493,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options))
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
@@ -502,7 +503,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options), (completed) => {
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
@@ -621,10 +622,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options) {
|
||||
function nextPath(options: Options, data: string) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(Global.Path.data, "opencode-next.db")
|
||||
return path.join(data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
@@ -25,6 +26,7 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
@@ -42,6 +44,7 @@ const layer = Layer.effect(
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
@@ -122,5 +125,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
})
|
||||
|
||||
@@ -18,8 +18,10 @@ export function make(input: {
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly bin: string
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findExecutable = (name: string) => which(name, undefined, input.bin)
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
@@ -37,7 +39,7 @@ export function make(input: {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("gofmt")
|
||||
const match = findExecutable("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -46,7 +48,7 @@ export function make(input: {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("mix")
|
||||
const match = findExecutable("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export function make(input: {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("zig")
|
||||
const match = findExecutable("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -159,7 +161,7 @@ export function make(input: {
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = which("clang-format")
|
||||
const match = findExecutable("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
@@ -168,7 +170,7 @@ export function make(input: {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("ktlint")
|
||||
const match = findExecutable("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -177,17 +179,18 @@ export function make(input: {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!which("ruff")) return disabled
|
||||
const bin = findExecutable("ruff")
|
||||
if (!bin) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return ["ruff", "format", "$FILE"]
|
||||
return [bin, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
@@ -197,7 +200,7 @@ export function make(input: {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("air")
|
||||
const bin = findExecutable("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
@@ -210,34 +213,34 @@ export function make(input: {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("uv")
|
||||
const bin = findExecutable("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = which("ocamlformat")
|
||||
const match = findExecutable("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
@@ -253,9 +256,9 @@ export function make(input: {
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
@@ -287,12 +290,17 @@ export function make(input: {
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
function executable(
|
||||
name: string,
|
||||
extensions: readonly string[],
|
||||
args: string[],
|
||||
findExecutable: (name: string) => string | null,
|
||||
): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which(name)
|
||||
const match = findExecutable(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ export const layer = Layer.effect(
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError": (error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
}),
|
||||
)
|
||||
if (!resolved)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
export * as InstructionDiscovery from "./instruction-discovery"
|
||||
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
@@ -17,7 +15,30 @@ class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
const Files = Schema.Array(File)
|
||||
const key = Instructions.Key.make("core/instructions")
|
||||
|
||||
export interface Interface {
|
||||
export const Event = {
|
||||
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<File>[]
|
||||
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
|
||||
// sibling contributors interleave by transform registration order.
|
||||
add: (file: File) => void
|
||||
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
|
||||
remove: (path: string) => void
|
||||
unavailable: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
// Discovery policy lives here because internal plugins have no per-composition options channel.
|
||||
// Move it into plugin config once plugins can consume their own options.
|
||||
readonly project: boolean
|
||||
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
|
||||
readonly load: () => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
@@ -32,9 +53,26 @@ export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "instruction-discovery",
|
||||
initial: () => ({ files: new Map(), available: true }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.files.values()),
|
||||
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = draft.files.get(AbsolutePath.make(path))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
},
|
||||
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
|
||||
unavailable: () => {
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<File>>({
|
||||
@@ -49,52 +87,22 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
})
|
||||
|
||||
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const fromProject = relative(stop, start)
|
||||
const insideProject =
|
||||
fromProject === "" ||
|
||||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(
|
||||
options?.project === false || !insideProject
|
||||
? []
|
||||
: yield* fs.up({
|
||||
targets: ["AGENTS.md"],
|
||||
start,
|
||||
stop,
|
||||
}),
|
||||
fs.resolve,
|
||||
),
|
||||
)
|
||||
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
|
||||
const files = yield* Effect.forEach(
|
||||
paths,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(
|
||||
Effect.map((content) =>
|
||||
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||
return Instructions.unavailable
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
const list = Effect.fn("InstructionDiscovery.list")(function* () {
|
||||
const current = state.get()
|
||||
if (!current.available) return Instructions.unavailable
|
||||
return Array.from(current.files.values())
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
),
|
||||
project: options?.project !== false,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list,
|
||||
load: Effect.fn("InstructionDiscovery.load")(function* () {
|
||||
const files = yield* list()
|
||||
if (!Array.isArray(files)) return source(files)
|
||||
return source(files.length === 0 ? Instructions.removed : files)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -103,7 +111,7 @@ export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Global.node, Location.node],
|
||||
deps: [Bus.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user