Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton bdb6fa54b9 fix(core): limit inherited permissions to denies 2026-08-12 19:55:44 -04:00
Kit Langton 168e8b603a fix(core): subagent sessions inherit ancestor permission rulesets 2026-08-12 19:22:23 -04:00
508 changed files with 10568 additions and 22183 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
-8
View File
@@ -1,8 +0,0 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
+1
View File
@@ -6,6 +6,7 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
-10
View File
@@ -76,16 +76,6 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify PowerShell 7 scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=pwsh bun run research:powershell
- name: Verify Windows PowerShell scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=powershell.exe bun run research:powershell
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
-48
View File
@@ -1,48 +0,0 @@
# he Glossary
## Sources
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose and UI copy)
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
- Keep `commit` and `diff` when they name the exact Git artifact or operation
## Preferred Terms
| English / Context | Preferred | Notes |
| ----------------- | ------------- | ------------------------------------------------------------------------- |
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
| workspace | `סביבת עבודה` | |
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
| command | `פקודה` | |
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
| model | `מודל` | |
| API key | `מפתח API` | Keep the acronym in Latin letters |
| plugin | `תוסף` | |
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
| branch | `ענף` | |
| context | `הקשר` | Use `חלון הקשר` for context window |
| tokens | `אסימונים` | |
## Guidance
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
- Use short action verbs for controls and translate complete phrases in context.
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
## Avoid
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
- Avoid translating `commit` as `התחייבות`.
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
+1 -1
View File
@@ -171,4 +171,4 @@ const table = sqliteTable("session", {
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
+89 -24
View File
@@ -70,13 +70,19 @@
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
@@ -139,7 +145,10 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
},
"devDependencies": {
@@ -188,15 +197,12 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"effect",
"solid-js",
],
},
"packages/codemode": {
@@ -385,7 +391,11 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -393,7 +403,6 @@
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/shell-scan": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -405,6 +414,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
},
@@ -581,7 +591,6 @@
"react-dom": "^19.2.7",
},
"devDependencies": {
"@napi-rs/canvas": "1.0.2",
"@types/bun": "catalog:",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -733,6 +742,8 @@
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
@@ -760,15 +771,6 @@
"vite": "catalog:",
},
},
"packages/shell-scan": {
"name": "@opencode-ai/shell-scan",
"version": "0.0.0",
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/simulation": {
"name": "@opencode-ai/simulation",
"version": "1.17.13",
@@ -955,6 +957,8 @@
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
@@ -967,6 +971,8 @@
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
@@ -1029,6 +1035,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"xdg-basedir": "5.1.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1094,6 +1101,7 @@
"unenv": "2.0.0-rc.24",
"vitest": "3.2.7",
"wrangler": "4.28.0",
"xdg-basedir": "5.1.0",
},
},
"packages/www": {
@@ -1113,9 +1121,12 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -1954,6 +1965,8 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -2146,8 +2159,6 @@
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
@@ -2734,7 +2745,7 @@
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
@@ -2748,11 +2759,11 @@
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
"@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
"@shikijs/twoslash": ["@shikijs/twoslash@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/types": "4.4.2", "twoslash": "^0.3.9" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-JjPKNbYCZn5+DVOxDZJ0ZruFPBM7m2JQBp4WbLh4tU2y3ed93wBCaQhKwMyfTFIsDzHks22szAkUUd2+jIPo/w=="],
"@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -2870,6 +2881,12 @@
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
"@solid-primitives/bounds": ["@solid-primitives/bounds@0.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="],
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="],
@@ -2894,14 +2911,20 @@
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA=="],
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12", "solid-start": "*" }, "optionalPeers": ["@tauri-apps/plugin-store", "solid-start"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="],
"@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="],
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
@@ -3234,6 +3257,8 @@
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
@@ -4896,9 +4921,9 @@
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -5718,6 +5743,10 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -5744,6 +5773,8 @@
"turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
@@ -5976,6 +6007,8 @@
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
@@ -6468,6 +6501,8 @@
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opencode-ai/workerd-spike/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="],
"@opencode-ai/workerd-spike/wrangler": ["wrangler@4.28.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.6.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250803.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.19", "workerd": "1.20250803.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250803.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-y0yHIuScpok9oSErLqDbxkBChC2+/jZpvqMg2NxOto1JCyUtDUuKljOfcVMaI48d9GuhOCSoWSumYxLAHNxaLA=="],
@@ -6492,6 +6527,8 @@
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@pierre/diffs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -6552,9 +6589,17 @@
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/twoslash/@shikijs/core": ["@shikijs/core@4.4.2", "", { "dependencies": { "@shikijs/primitive": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA=="],
@@ -6950,6 +6995,10 @@
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
@@ -6992,6 +7041,8 @@
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
@@ -7616,6 +7667,10 @@
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@opencode-ai/workerd-spike/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"@opencode-ai/workerd-spike/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -7694,6 +7749,10 @@
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@radix-ui/react-arrow/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"@radix-ui/react-collapsible/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
@@ -7734,6 +7793,8 @@
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/twoslash/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA=="],
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -7986,6 +8047,10 @@
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+4
View File
@@ -145,6 +145,10 @@
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
-2
View File
@@ -22,8 +22,6 @@
## 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.
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
@@ -90,8 +90,6 @@ export async function setupTimeline(
page: Page,
input: {
messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
@@ -104,22 +102,13 @@ export async function setupTimeline(
} = {},
) {
const sessions = input.sessions ?? [session()]
const messages =
input.currentMessages ??
validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) =>
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
)
const messages = validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) => message.info.role === "assistant")
const initialStatus = decodeStatus(
active &&
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.time.completed === undefined)
? { type: "busy" }
: { type: "idle" },
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
decodeOptions,
)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
@@ -133,7 +122,7 @@ export async function setupTimeline(
project: project(),
provider: provider(),
sessions,
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
sessionStatus: { [sessionID]: initialStatus },
pageMessages: () => ({
items: messages,
}),
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -79,8 +78,6 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -88,6 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -144,3 +142,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -13,8 +13,6 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
await configureServers(page)
@@ -48,7 +46,6 @@ test("session settings use the remote server context", async ({ page }) => {
test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -95,22 +95,22 @@ test("opens and searches project files inline", async ({ page }) => {
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -125,21 +125,21 @@ test("opens and searches project files inline", async ({ page }) => {
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
@@ -11,23 +11,18 @@ const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
forms: [
questions: [
{
id: "frm_question_request",
id: "question-request",
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [
questions: [
{
key: "q0",
type: "string",
title: "Implementation",
description: "Which implementation should be used?",
header: "Implementation",
question: "Which implementation should be used?",
options: [
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
{ value: "extended", label: "Extended", description: "Include additional behavior" },
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
],
custom: true,
},
],
},
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
})
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
const reply = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})
test("shows a pending permission dock", async ({ page }) => {
@@ -87,7 +82,7 @@ test("shows a pending permission dock", async ({ page }) => {
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: ["git *"],
always: [],
},
],
})
@@ -114,7 +109,7 @@ test("restores the draft caret before typing after a request dock closes", async
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { forms: [] })
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "form.created",
type: "question.asked",
properties: {
form: {
id: "frm_question_caret",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
fields: [
{
key: "q0",
type: "string",
title: "Continue",
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
@@ -184,8 +170,6 @@ async function mockServer(
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
},
) {
await mockOpenCodeServer(page, {
@@ -230,8 +214,6 @@ async function mockServer(
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
forms: requests.forms,
sessionStatus: requests.sessionStatus,
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
@@ -1,184 +0,0 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) =>
({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
currentMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
assistant(true),
{
id: "msg_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: 4 },
},
{
id: "msg_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: 5 },
},
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
],
})
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
expect(ownerWarnings).toEqual([])
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
currentMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
currentMessages: [user, assistant(true)],
sessions: [session(), session({ id: childID, parentID: sessionID })],
sessionStatus: { [childID]: { type: "busy" } },
})
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
await setupTimeline(page, {
currentMessages: [
user,
{
id: "msg_backgrounded",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_backgrounded",
name: "subagent",
state: {
status: "completed",
input: { description: "Background task" },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: backgroundID, status: "running" },
},
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "call_shell_backgrounded",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_backgrounded", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_blocking",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_blocking",
name: "subagent",
state: {
status: "running",
input: { description: "Foreground task" },
metadata: { sessionID: blockingID },
},
time: { created: 4 },
},
],
time: { created: 4 },
},
],
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
[backgroundID]: { type: "busy" },
[blockingID]: { type: "busy" },
},
})
const dock = page.locator('[data-component="session-background-dock"]')
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
})
@@ -27,30 +27,6 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
() => release.resolve(),
)
await expectSessionTitle(page, taskDescription)
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: EventPayload[] = []
await setup(page, () => events.splice(0, 1))
+8 -151
View File
@@ -32,110 +32,15 @@ export interface MockServerConfig {
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
}
type MockStreamWindow = Window & {
__testSseTransport?: unknown
__mockServerStream?: { push: (payloads: unknown[]) => void }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
await page.addInitScript(
({ port, retry }) => {
const host = window as MockStreamWindow
if (host.__testSseTransport || host.__mockServerStream) return
const originalFetch = window.fetch.bind(window)
const encoder = new TextEncoder()
const state: {
controller?: ReadableStreamDefaultController<Uint8Array>
buffer: string[]
connections: number
} = { buffer: [], connections: 0 }
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`
host.__mockServerStream = {
push(payloads: unknown[]) {
const frames = payloads.map(frame)
const controller = state.controller
if (!controller) {
state.buffer.push(...frames)
return
}
frames.forEach((item) => controller.enqueue(encoder.encode(item)))
},
}
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
state.connections += 1
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
state.controller = controller
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
{ once: true },
)
},
cancel() {
if (ended) return
ended = true
if (state.controller === own) state.controller = undefined
},
})
return Promise.resolve(
new Response(stream, {
status: 200,
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
}),
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
)
if (config.events) {
const pump = { busy: false }
const timer = setInterval(() => {
if (pump.busy) return
const batch = config.events?.() ?? []
if (batch.length === 0) return
pump.busy = true
void page
.evaluate(
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
batch.map(currentEvent),
)
.catch(() => {})
.finally(() => {
pump.busy = false
})
}, 50)
page.on("close", () => clearInterval(timer))
}
const staticRoutes: Record<string, unknown> = {
"/path": {
state: config.directory,
@@ -267,34 +172,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
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 })
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
if (projectCopy && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
if (projectCopy && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
@@ -308,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
@@ -361,10 +243,7 @@ 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) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
@@ -401,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
@@ -599,7 +466,6 @@ 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,
@@ -617,19 +483,10 @@ export function currentSession(session: { id: string } & Record<string, unknown>
},
title: session.title ?? session.id,
location: {
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 }
: {}),
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
subpath: session.subpath ?? session.path,
subpath: session.path,
revert: session.revert,
}
}
+6
View File
@@ -63,13 +63,19 @@
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
+257 -73
View File
@@ -1,38 +1,48 @@
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"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createRenderEffect,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk"
@@ -40,26 +50,26 @@ import { WslServersProvider } from "@/wsl/context"
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 { decode64 } from "@/utils/base64"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const server = useServers()
const server = useServer()
const tabs = useTabs()
createEffect(() => {
if (search.draftId || !tabs.ready()) return
const directory = decode64(params.dir)
if (!directory) return
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
tabs.newDraft({ server: server.key, directory }, search.prompt)
})
return null
@@ -76,13 +86,31 @@ function TargetServerRoute(props: ParentProps) {
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed>
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
<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) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
@@ -102,24 +130,24 @@ function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<SDKProvider directory={props.draft.directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</ServerProvider>
)}
</Show>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
@@ -128,13 +156,7 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>
{props.children}
</I18nProvider>
@@ -158,8 +180,8 @@ function QueryProvider(props: ParentProps) {
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
@@ -179,6 +201,18 @@ function BodyDesignClass() {
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</>
)
}
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
@@ -202,14 +236,30 @@ function DesktopCommands() {
return null
}
function AppLayout(props: ParentProps) {
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<LayoutProvider>
<Layout>{props.children}</Layout>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
)
}
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<Layout>{props.children}</Layout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
@@ -259,6 +309,133 @@ export function AppBaseProviders(
)
}
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
const [startup] = createResource(async () => {
if (!props.startup) return true
await props.startup.catch((error) => {
console.error("[startup] startup gate failed", error)
})
return true
})
const startupChecking = createMemo(
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
)
const loading = createMemo(() => checking() || startupChecking())
return (
<>
<Show when={!checking()}>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
<Show when={loading()}>
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
</Show>
</>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
@@ -267,60 +444,67 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
startup?: Promise<void>
serverScoped?: JSX.Element
}) {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</TabsProvider>
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
)
return (
<ServersProvider
<ServerProvider
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
{/* Proper Routes */}
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
{/* Legacy Routes */}
<Route>
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
</Route>
</Dynamic>
</GlobalProvider>
</SettingsProvider>
</ServersProvider>
<GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider>
)}
>
<Routes />
</Dynamic>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
return (
<>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
<Route path="/new-session" component={DraftRoute} />
</>
)
}
function LegacySessionRedirect() {
const server = useServers()
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
@@ -331,7 +515,7 @@ function LegacySessionRedirect() {
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
params.id,
ServerConnection.key(server.list[0]!),
server.key,
),
params.id,
)}
@@ -8,13 +8,12 @@ import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = {
id: string
@@ -84,7 +83,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const language = useLanguage()
const file = useFile()
const dialog = useDialog()
const serverSDK = useServerSDK()
const serverSDK = useServerSDK()()
const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout()
+3 -3
View File
@@ -1,5 +1,5 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
<Show when={platform.setForceFocus}>
{platform.setForceFocus && (
<ToggleCell
active={state.focus}
inline={props.inline}
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
</Show>
)}
</div>
</aside>
)
@@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time"
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
@@ -41,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
}
export const DialogConnectProvider: Component<{
directory?: string
directory?: Accessor<string | undefined>
controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => {
const fallback = useProviderConnectController()
@@ -137,11 +136,15 @@ export const DialogConnectProvider: Component<{
)
}
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
function ProviderPicker(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const integrations = useIntegrations(() => props.directory)
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -163,7 +166,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -208,8 +211,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
)
}
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
const integrations = useIntegrations(() => props.directory)
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
@@ -221,7 +228,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()]
const values = [custom(), ...providers.all().values()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
@@ -357,7 +364,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
function ProviderConnection(props: {
provider: string
directory?: string
directory?: Accessor<string | undefined>
onBack: () => void
setBack: (handler: () => void) => void
}) {
@@ -367,9 +374,12 @@ function ProviderConnection(props: {
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const controller = createProviderConnectionController({
provider: () => props.provider,
directory,
@@ -383,14 +393,6 @@ function ProviderConnection(props: {
})
},
})
const provider = createMemo(() => ({
id: props.provider,
name:
providers.all().get(props.provider)?.name ??
serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
disabledProviders: serverSync().data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
})
batch(() => {
setForm("err", output.err)
@@ -1,238 +0,0 @@
.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,223 +1,156 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon } from "@opencode-ai/ui/v2/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, createSignal, startTransition } from "solid-js"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { SDKProvider } from "@/context/sdk"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { 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 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"
/>
<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>
<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>
<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>
<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)}
<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]"
/>
</Field>
<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}
/>
<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 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"
<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={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
"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,
)
}}
>
<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>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
<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>
</Show>
<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>
<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>
</Dialog>
)
}
+1 -5
View File
@@ -11,9 +11,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { sessionHref } from "@/utils/session-route"
import { ServerConnection } from "@/context/servers"
interface ForkableMessage {
id: string
@@ -30,7 +27,6 @@ export const DialogFork: Component = () => {
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const serverSDK = useServerSDK()
const prompt = usePrompt()
const dialog = useDialog()
const language = useLanguage()
@@ -77,7 +73,7 @@ export const DialogFork: Component = () => {
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
navigate(`/${dir}/session/${forked.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
@@ -1,4 +1,4 @@
import { createSignal, Index, Show } from "solid-js"
import { createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -83,71 +83,61 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
{/* Bottom section - buttons and indicators (fixed position) */}
<div class="flex flex-col gap-12">
<div class="flex flex-col items-start gap-3">
<Show
when={isLast()}
fallback={
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
}
>
{isLast() ? (
<Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")}
</Button>
</Show>
) : (
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
)}
<Button variant="ghost" size="small" onClick={handleDisable}>
{language.t("dialog.releaseNotes.action.hideFuture")}
</Button>
</div>
<Show when={paged()}>
{paged() && (
<div class="flex items-center gap-1.5 -my-2.5">
<Index each={props.highlights}>
{(_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
{props.highlights.map((_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
classList={{
"w-8": i === index(),
"w-3": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"w-8": i === index(),
"w-3": i !== index(),
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
/>
</button>
)}
</Index>
/>
</button>
))}
</div>
</Show>
)}
</div>
</div>
{/* Right side - Media content (edge to edge) */}
<Show when={feature()?.media}>
{(media) => (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
<Show
when={media().type === "image"}
fallback={
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
}
>
<img
src={media().src}
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
class="w-full h-full object-cover"
/>
</Show>
</div>
)}
</Show>
{feature()?.media && (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
{feature()!.media!.type === "image" ? (
<img
src={feature()!.media!.src}
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
class="w-full h-full object-cover"
/>
) : (
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
)}
</div>
)}
</div>
</Dialog>
)
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import type { Path } from "@/types"
import {
absoluteTreePath,
@@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
@@ -9,6 +9,7 @@ import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
@@ -28,13 +29,14 @@ type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const platform = usePlatform()
const settings = useSettings()
const filesOnly = () => props.mode === "files"
if (!filesOnly()) {
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
if (filesOnly() && platform.platform === "desktop") {
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
@@ -50,7 +52,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void
return (
<DialogSelectFileV2
server={serverSDK.server}
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useIntegrations } from "@/hooks/use-integrations"
import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const integrations = useIntegrations(directory)
const providers = useProviders(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
@@ -127,8 +127,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={integrations
.list()
each={[...providers.popular()]
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => {
close("provider")
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
const language = useLanguage()
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => {
const selected = props.current
const selected = props.current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current}>
<MenuV2.RadioGroup value={props.current()}>
<For each={group.items}>
{(item) => (
<TooltipV2
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={props.current === modelKey(item) ? true : undefined}
data-selected-model={props.current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const provider = () => {
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
@@ -1,13 +1,17 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerCollectionController } from "@/components/server/server-management-controller"
import { ServerConnection } from "@/context/server"
import { useSettings } from "@/context/settings"
import { type ServerDomainController } from "@/components/server/server-management-controller"
type ServerConnectionFormController = {
state: {
@@ -113,11 +117,12 @@ function ServerForm(props: ServerFormProps) {
}
export function ServerConnectionList(props: {
domain: ServerCollectionController
domain: ServerDomainController
onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
}) {
const language = useLanguage()
const settings = useSettings()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
@@ -131,6 +136,9 @@ export function ServerConnectionList(props: {
emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items}
key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}}
divider={true}
>
{(i) => {
@@ -155,6 +163,15 @@ export function ServerConnectionList(props: {
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
@@ -1,4 +1,4 @@
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
@@ -1,5 +1,5 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js"
+1 -1
View File
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
@@ -1,238 +0,0 @@
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,7 +37,6 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
accentSubmit?: boolean
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -54,7 +53,6 @@ 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}
+2 -6
View File
@@ -45,6 +45,7 @@ import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
@@ -1517,7 +1518,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
dir={store.mode === "normal" ? "auto" : "ltr"}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
@@ -1539,11 +1539,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
style={{
"padding-bottom": space,
"unicode-bidi": store.mode === "normal" ? "plaintext" : undefined,
"text-align": "start",
}}
style={{ "padding-bottom": space }}
/>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
@@ -1,16 +1,18 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import { ServerScope } from "@/utils/server-scope"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
type SessionCreateInput = {
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -20,9 +22,11 @@ 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 sentShellDirectories: string[] = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
@@ -33,29 +37,15 @@ const switchedModels: Array<{
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
const commands: Array<{ name: string }> = []
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 locationFailure: Error | undefined
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>({
@@ -83,25 +73,21 @@ const prompt = {
replaceComments: () => undefined,
items: () => [],
},
capture: (scope?: unknown, target?: unknown) => {
promptCaptures.push({ scope, target })
return prompt
},
capture: () => prompt,
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
api: {
session: {
create: async (input: SessionCreateInput) => {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
const id = `session-${createdSessions.length}`
sessionDirectories[id] = location
sessionCreateInputs.push(input)
return {
id,
id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
@@ -114,7 +100,7 @@ const clientFor = (directory: string) => {
},
prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
@@ -134,21 +120,6 @@ const clientFor = (directory: string) => {
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
},
},
worktree: {
create: async (_input: unknown) => {
worktreeCreates++
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { directory: worktreeDirectory }
},
},
location: {
get: async () => {
if (locationFailure) throw locationFailure
return { directory: worktreeDirectory }
},
},
},
@@ -156,6 +127,9 @@ const clientFor = (directory: string) => {
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
}
}
@@ -171,7 +145,6 @@ beforeAll(async () => {
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0,
}))
@@ -189,13 +162,20 @@ beforeAll(async () => {
current: () => ({ name: "agent" }),
},
session: {
promote: () => undefined,
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
},
}),
}))
mock.module("@/context/permission", () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
})
mock.module("@/context/server", () => ({
@@ -204,10 +184,7 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({
useTabs: () => ({
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
draft: () => ({ server: "project-server" }),
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session })
},
@@ -218,72 +195,78 @@ beforeAll(async () => {
usePrompt: () => prompt,
}))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
setTabs: () => undefined,
},
}),
}))
mock.module("@/context/sdk", () => ({
useSDK: () => {
return () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
const sdk = {
scope: "local",
directory: "/repo/main",
api: rootClient.api,
url: "http://localhost:4096",
})
}
return () => sdk
},
}))
mock.module("@/context/sync", () => ({
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,
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,
)
},
remove: () => undefined,
},
set: () => undefined,
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
},
set: () => undefined,
}),
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => {
const server = activeServerSync
return {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
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 }>
}
},
},
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", () => ({
@@ -303,141 +286,205 @@ 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
syncedServers.length = 0
optimisticServers.length = 0
promptCaptures.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
sentShell.length = 0
sentShellDirectories.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
permissionServer = "server-a"
createSessionGate = undefined
serverSessionSyncs = 0
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
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("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
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,
})
const submit = makeSubmit()
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
const event = { preventDefault: () => undefined } as unknown as Event
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
await settle()
await submit.handleSubmit(event)
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
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"])
})
test("stops when the created workspace cannot initialize", async () => {
selected = "create"
locationFailure = new Error("initialization failed")
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,
})
await makeSubmit().handleSubmit(event)
const event = { preventDefault: () => undefined } as unknown as Event
expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
expect(sentPrompts).toEqual([])
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
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"
test("keeps auto-accept bound to the submission server", async () => {
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
let submitted = 0
const submit = makeSubmit({
onSubmit: () => submitted++,
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,
})
const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
release()
await result
await settle()
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(submitted).toBe(0)
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" }])
})
test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" }
variant = "high"
const submit = makeSubmit({
const submit = createPromptSubmit({
prompt,
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)
@@ -472,12 +519,24 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = makeSubmit({
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.handleSubmit(event)
await settle()
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
@@ -493,19 +552,66 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0)
})
test("sends an initial shell after synchronous workspace creation", async () => {
selected = "create"
const submit = makeSubmit({
mode: () => "shell",
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,
})
await submit.handleSubmit(event)
await settle()
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(sentShell[0]).toMatchObject({
sessionID: "session-1",
command: "ls",
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)
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])
})
})
+289 -216
View File
@@ -8,12 +8,14 @@ import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
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 { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
@@ -23,7 +25,12 @@ import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
const submitting = new Set<string>()
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
export type FollowupDraft = {
sessionID: string
@@ -43,6 +50,7 @@ type FollowupSendInput = {
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -62,11 +70,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
}
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy()
try {
if (!(await wait())) {
setIdle()
return false
}
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
@@ -140,6 +159,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
try {
if (!(await wait())) {
batch(() => {
setIdle()
remove()
})
return false
}
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
@@ -231,10 +258,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const local = useLocal()
const permission = usePermission()
const prompt = input.prompt
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) {
@@ -248,10 +278,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync.session.set("todo", sessionID, [])
serverSync().session.set("todo", sessionID, [])
input.onAbort?.()
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.catch(() => {})
@@ -280,9 +319,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
const seed = (dir: string, info: SessionInfo) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -314,6 +353,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort()
return
}
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
@@ -326,251 +366,284 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const submissionSDK = sdk()
const submissionSync = sync()
const submissionServerSync = serverSync
const submissionScope = submissionSDK.scope
const projectDirectory = submissionSDK.directory
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 resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
const permissionState = permission
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
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)
try {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await submissionSDK.api.worktree
.create({
projectID: submissionSync.data.project,
strategy: "git",
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
})
.then(async (created) => {
await submissionSDK.api.location.get({ location: { directory: created.directory } })
return created
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
})
if (!createdWorktree) return
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 },
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 },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
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,
})
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 },
),
)
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 (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
return undefined
})
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 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()
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 }))
})
return true
}
}
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
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 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
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
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()
return
}
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionSDK.api.session
.shell({
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id,
id: eventID,
command: text,
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) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
}
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")
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void 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 commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
void sendFollowupDraft({
api: submissionSDK.api.session,
sync: submissionSync,
serverSync: submissionServerSync,
session: () => session,
draft,
const removeOptimisticMessage = () => {
sync().session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
})
}
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" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" })
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)
}
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
}
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
} finally {
submitting.delete(submissionKey)
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
}
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,52 +1,35 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { 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/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace"
export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const [search, setSearch] = createSignal("")
let searchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(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))
})
let pending: string | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
return "workspace"
}
const select = (value: string) => {
pending = { type: "select", value }
pending = value
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch("")
return
}
const action = pending
if (open) return
const value = pending
pending = undefined
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
if (value) props.onChange(value)
props.onDone()
}
const label = () => {
@@ -58,214 +41,87 @@ export function PromptWorkspaceSelector(props: {
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<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"
/>
<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>
</Show>
<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 ps-3 pe-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="ms-1" />
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<PromptGitStatus branch={props.branch} />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
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) => (
<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>
<>
<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>
</>
)}
</Show>
)
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
serverSDK()
.api.integration.get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
cancelPolling()
const directory = options.directory()
await queryClient
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK.api.integration.oauth
.status({
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
return
}
dispatch({ type: "auth.pending" })
const result = await serverSDK.api.integration.oauth
.connect({
const result = await serverSDK()
.api.integration.oauth.connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK.api.integration.connect.key({
await serverSDK().api.integration.connect.key({
integrationID: options.provider(),
location: location(),
key,
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK.api.integration.oauth
.complete({
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
return {
loading: () => integration.loading,
integration: () => integration.latest,
methods,
currentMethod,
methodIndex: () => store.methodIndex,
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServers } from "@/context/servers"
import { ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health"
@@ -49,7 +49,7 @@ function useDefaultServer() {
}
export function useServerActionsController() {
const server = useServers()
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
@@ -72,16 +72,28 @@ export function useServerActionsController() {
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServers()
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => server.list)
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
@@ -89,6 +101,8 @@ export function useServerCollectionController() {
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
@@ -99,6 +113,7 @@ export function useServerCollectionController() {
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
@@ -106,3 +121,21 @@ export function useServerCollectionController() {
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
@@ -65,17 +65,20 @@ describe("replaceServerConnection", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
@@ -84,8 +87,10 @@ describe("replaceServerConnection", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
@@ -1,4 +1,4 @@
import { normalizeServerUrl, ServerConnection } from "@/context/servers"
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
@@ -42,13 +42,18 @@ export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
@@ -11,7 +11,7 @@ import {
Show,
} from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/servers"
import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
{(conn) => (
<div class="flex flex-row gap-3">
<span>
<Show
when={conn().http.username}
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
>
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
</Show>
) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)}
</span>
<Show when={conn().http.password}>
<span class="text-text-weak"></span>
</Show>
{conn().http.password && <span class="text-text-weak"></span>}
</div>
)}
</Show>
@@ -39,7 +39,8 @@ function openSessionContext(args: {
}) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context").then(() => args.tabs.setActive("context"))
void args.tabs.open("context")
args.tabs.setActive("context")
}
export function SessionContextUsage(props: SessionContextUsageProps) {
@@ -1,136 +0,0 @@
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 { createSignal, 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 { showToast } from "@/utils/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/utils/workspace"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
sessionID: string
project: Project
directory: 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 [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK
const sessionID = props.sessionID
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: 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={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, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useServer } from "@/context/server"
export const OPEN_APPS = [
"vscode",
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
@@ -1,12 +1,32 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createMemo, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@/utils/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -14,15 +34,206 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const LINUX_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const { view } = useSessionLayout()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
const apps = createMemo(() => {
if (os() === "macos") return MAC_APPS
if (os() === "windows") return WINDOWS_APPS
return LINUX_APPS
})
const fileManager = createMemo(() => fileManagerApp(os()))
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
@@ -33,16 +244,275 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = projectDirectory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = projectDirectory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const rightMount = useTitlebarRightMount()
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
})
return (
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Portal>
)}
</Show>
<>
<Show when={search() && centerMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={hotkey()} keyed>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
</Show>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Show
when={isV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
)
}
@@ -57,6 +527,8 @@ type SessionHeaderV2ActionsState = {
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
tab: string
index: number
index: () => number
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
return props.tab
},
get index() {
return props.index
return props.index()
},
})
const path = createMemo(() => file.pathFromTab(props.tab))
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTabV2(props: {
terminal: LocalPTY
index: number
index: () => number
newLayout: boolean
onClose?: () => void
}): JSX.Element {
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
return props.terminal.id
},
get index() {
return props.index
return props.index()
},
})
const [store, setStore] = createStore({
+329 -13
View File
@@ -1,6 +1,10 @@
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
@@ -9,6 +13,7 @@ import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
import { SettingsListV2 } from "./settings-v2/parts/list"
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
@@ -196,6 +201,69 @@ function filteredFor(
return out
}
function useKeyCapture(input: {
active: () => string | null
stop: () => void
set: (id: string, keybind: string) => void
used: () => Map<string, { id: string; title: string }[]>
language: ReturnType<typeof useLanguage>
}) {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = input.active()
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
input.stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.set(id, "none")
input.stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
for (const item of input.used().get(sig) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, input.language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.set(id, next)
input.stop()
}
makeEventListener(document, "keydown", handle, { capture: true })
})
}
export function createKeybindSettingsController(
input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
@@ -342,7 +410,7 @@ export function createKeybindSettingsController(
}
}
export function SettingsKeybinds() {
function SettingsKeybindsV2() {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
@@ -356,9 +424,9 @@ export function SettingsKeybinds() {
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active()}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides()}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
/>
)
@@ -369,9 +437,9 @@ function SettingsKeybindsV2View(props: {
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: string | null
active: () => string | null
onCapture: (id: string) => void
hasOverrides: boolean
hasOverrides: () => boolean
onReset: () => void
}) {
const language = useLanguage()
@@ -383,11 +451,8 @@ function SettingsKeybindsV2View(props: {
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<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}>
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
@@ -433,12 +498,12 @@ function SettingsKeybindsV2View(props: {
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active === id,
"settings-v2-keybind-button--active": props.active() === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active === id}
when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
@@ -463,3 +528,254 @@ function SettingsKeybindsV2View(props: {
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
active: null as string | null,
filter: "",
})
const stop = () => {
if (!store.active) return
setStore("active", null)
command.keybinds(true)
}
const start = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
command.keybinds(false)
}
const map = createMemo(() => keybinds(settings.current.keybinds))
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
const resetAll = () => {
stop()
settings.keybinds.resetAll()
showToast({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
}
const list = createMemo(() => {
language.locale()
return listFor(command, map(), language.t("command.palette"))
})
const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => groupedFor(list()))
const filtered = createMemo(() => {
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
})
const hasResults = createMemo(() => {
for (const group of GROUPS) {
const ids = filtered().get(group) ?? []
if (ids.length > 0) return true
}
return false
})
const used = createMemo(() => {
const map = new Map<string, { id: string; title: string }[]>()
const add = (key: string, value: { id: string; title: string }) => {
const list = map.get(key)
if (!list) {
map.set(key, [value])
return
}
list.push(value)
}
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
for (const sig of signatures(palette)) {
add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
}
const valueFor = (id: string) => {
const custom = settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = command.options.find((x) => x.id === id)
if (live?.keybind) return live.keybind
const meta = command.catalog.find((x) => x.id === id)
return meta?.keybind
}
for (const id of list().keys()) {
if (id === PALETTE_ID) continue
for (const sig of signatures(valueFor(id))) {
add(sig, { id, title: title(id) })
}
}
return map
})
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
useKeyCapture({
active: () => store.active,
stop,
set: setKeybind,
used,
language,
})
onCleanup(() => {
if (store.active) command.keybinds(true)
})
const emptyResults = (
<Show when={store.filter && !hasResults()}>
<div
classList={{
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
"settings-v2-shortcuts-status": props.v2,
}}
>
<span
classList={{
"text-14-regular text-text-weak": !props.v2,
}}
>
{language.t("settings.shortcuts.search.empty")}
</span>
<Show when={store.filter}>
<span
classList={{
"text-14-regular text-text-strong mt-1": !props.v2,
"settings-v2-shortcuts-status-filter": props.v2,
}}
>
&quot;{store.filter}&quot;
</span>
</Show>
</div>
</Show>
)
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
"flex flex-col gap-8 max-w-[720px]": !props.v2,
}}
>
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div
classList={{
"settings-v2-section": props.v2,
"flex flex-col gap-1": !props.v2,
}}
>
<h3
classList={{
"settings-v2-section-title": props.v2,
"text-14-medium text-text-strong pb-2": !props.v2,
}}
>
{language.t(groupKey[group])}
</h3>
<List>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span
classList={{
"text-14-regular text-text-strong": !props.v2,
}}
>
{title(id)}
</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": props.v2,
"settings-v2-keybind-button--active": props.v2 && store.active === id,
"h-8 px-3 rounded-md text-12-regular": !props.v2,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
!props.v2 && store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak":
!props.v2 && store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</List>
</div>
</Show>
)}
</For>
{emptyResults}
</div>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
)
}
@@ -1,26 +0,0 @@
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { ModelsProvider } from "@/context/models"
import { ServerProvider } from "@/context/server"
import { ServerConnection } from "@/context/servers"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerProvider conn={props.server}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</ServerProvider>
)
}
@@ -1,134 +0,0 @@
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>
</>
)
}
@@ -13,7 +13,7 @@ import {
} from "@/components/server/server-management"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useCheckServerHealth } from "@/utils/server-health"
import "./settings-v2.css"
@@ -146,7 +146,7 @@ export const DialogServerV2: Component<{
}
function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServers()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const language = useLanguage()
@@ -173,7 +173,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
})
}
const allServers = () => {
return server.list
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
@@ -181,8 +182,10 @@ function createFormController(options: { onSelect?: () => void } = {}) {
const add = (connection: ServerConnection.Http) => server.add(connection)
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add,
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
})
@@ -1,26 +1,19 @@
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneral } from "./general"
import { SettingsAppearanceV2 } from "./appearance"
import { SettingsGeneralV2 } from "./general"
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 { useGlobal, useServerCtx } from "@/context/global"
import { ServerConnection, useServers } from "@/context/servers"
import "./settings-v2.css"
import { useServerSync } from "@/context/server-sync"
export const DialogSettings: Component<{
sessionID?: string
@@ -30,40 +23,17 @@ export const DialogSettings: Component<{
const platform = usePlatform()
const dialog = useDialog()
const layout = useLayout()
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const server = createMemo(() => {
const route = layout.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
}
case "session":
return servers.list.find((item) => ServerConnection.key(item) === route.server)
case "home":
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
}
})
const serverCtx = useServerCtx(server)
createEffect(() => {
const current = server()
if (current) global.settings.server.set(ServerConnection.key(current))
})
const directory = createMemo(() => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
return undefined
})
@@ -82,99 +52,62 @@ export const DialogSettings: Component<{
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<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-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>
{/* Group 2: Environment & Workspaces */}
<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>
{/* Group 3: Capabilities & Extensions */}
<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 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>
</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">
<SettingsGeneral server={server()} 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 />
<SettingsGeneralV2 sessionID={props.sessionID} />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds />
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="projects" class="settings-v2-panel">
<SettingsProjectsV2 />
<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>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</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>
<TabsV2.Content value="extensions" class="settings-v2-panel">
<SettingsExtensionsV2 />
</TabsV2.Content>
</SettingsServerScope>
</TabsV2>
</Dialog>
)
@@ -1,154 +0,0 @@
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>
</>
)
}
@@ -17,24 +17,17 @@ import {
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
import { ServerConnection } from "@/context/servers"
import { useGlobal, useServerCtx } from "@/context/global"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(
server: Accessor<ServerConnection.Any | undefined>,
sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
const permission = usePermission()
const serverSync = useServerSync()
const directory = createMemo(() => {
const s = server()
const id = sessionID()
if (!s || !id) return undefined
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
if (!id) return undefined
return serverSync().session.lineage.peek(id)?.session.location.directory
})
return {
@@ -42,21 +35,21 @@ export function createPermissionScopeController(
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission()?.isAutoAccepting(id, dir)
return permission.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission()?.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir)
if (checked) return permission.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
export function createShellSettingsController() {
const serverSync = useServerSync()
const [shells] = createResource(
async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs.
@@ -65,7 +58,7 @@ export function createShellSettingsController(server: Accessor<ServerConnection.
},
{ initialValue: [] as ShellOption[] },
)
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
const current = createMemo(() => serverSync().data.config.shell ?? "")
return {
shells: () => shells.latest,
@@ -73,7 +66,7 @@ export function createShellSettingsController(server: Accessor<ServerConnection.
select: (value: string) => {
if (value === current()) return
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// void serverSync.updateConfig({ shell: value })
// void serverSync().updateConfig({ shell: value })
},
}
}
@@ -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 { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
import { useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
@@ -24,7 +24,6 @@ import {
type SoundSettingsController,
} from "./general-controllers"
import "./settings-v2.css"
import { ServerConnection } from "@/context/servers"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
@@ -86,34 +85,6 @@ 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(() =>
@@ -298,20 +269,18 @@ const LanguageSetting = () => {
)
}
export const SettingsGeneral: Component<{
export const SettingsGeneralV2: Component<{
sessionID?: string
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(
() => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
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(
@@ -329,11 +298,9 @@ export const SettingsGeneral: 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} />
@@ -396,6 +363,18 @@ export const SettingsGeneral: 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")}
@@ -531,18 +510,18 @@ export const SettingsGeneral: Component<{
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.preferences")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.preferences.description")}
</span>
</div>
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
</Show>
@@ -11,7 +11,6 @@ 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"
@@ -25,7 +24,7 @@ export const SettingsModelsV2: Component = () => {
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<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>
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
@@ -1,124 +0,0 @@
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>
</>
)
}
@@ -1,49 +0,0 @@
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/servers"
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>
)
}
@@ -1,139 +0,0 @@
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/servers"
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>
</>
)
}
@@ -4,17 +4,14 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show, createEffect } from "solid-js"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { 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"
import { useData } from "@/context/server"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -33,36 +30,25 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{
directory: string | undefined
directory: Accessor<string | undefined>
onBack?: () => void
}> = (props) => {
const data = useData()
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => props.directory)
const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const connected = createMemo(() => {
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
}),
)
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
@@ -99,22 +85,45 @@ export const SettingsProvidersV2: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
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.api.integration
.get({ integrationID: providerID, location })
const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.api.integration.get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
)
showToast({
variant: "success",
@@ -132,13 +141,7 @@ export const SettingsProvidersV2: Component<{
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.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-providers">
@@ -241,11 +244,7 @@ export const SettingsProvidersV2: Component<{
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/servers"
import { ServerConnection, serverName } from "@/context/server"
import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<div class="settings-v2-tab-header-row">
<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>
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
@@ -39,14 +39,6 @@
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;
@@ -182,13 +174,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;
@@ -692,223 +684,6 @@
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);
@@ -952,24 +727,3 @@
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;
}
@@ -1,502 +0,0 @@
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 { 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 { InlineServerSelect } from "./parts/server-select"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/servers"
import type { Project } from "@/types"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
managedWorkspaceDirectories,
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) => managedWorkspaceDirectories(project).length > 0),
)
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 sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
]),
),
)
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(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 inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.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),
)
project.worktrees = project.worktrees.filter(
(worktree) => pathKey(worktree.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)}
inspectionMessages={inspectionMessages}
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">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</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}>
<MenuV2 placement="bottom-end" gutter={6}>
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium 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">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<For each={projectOptions()}>
{(option) => (
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</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}
dir="ltr"
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[] }>
inspectionMessages: (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 descriptions = () => {
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
}
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")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</>
}
/>
</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.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -1,17 +1,25 @@
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
import {
type Accessor,
createEffect,
createMemo,
createResource,
For,
type JSXElement,
onCleanup,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServers } from "@/context/servers"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -101,9 +109,12 @@ type ServerStatusItem = {
onSelect: () => void
}
export function StatusPopoverBody(props: { shown: boolean }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const sdk = useSDK()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const fail = (err: unknown) => {
@@ -114,18 +125,29 @@ export function StatusPopoverBody(props: { shown: boolean }) {
})
}
createEffect(() => {
if (!props.shown()) return
})
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => {
const list = global.servers.list()
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
() => (props.shown() ? sdk().directory : undefined),
(directory) =>
sdk()
.api.plugin.list({ location: { directory } })
@@ -5,7 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection, useServers } from "@/context/servers"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
@@ -14,7 +14,6 @@ import {
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
import { useServer } from "@/context/server"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -75,7 +74,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown()} />
<Body shown={shown} />
</Suspense>
</Show>
</Popover>
@@ -83,12 +82,16 @@ export function StatusPopover() {
}
export function StatusPopoverV2() {
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServer()
const server = useServerSDK()
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
@@ -111,7 +114,7 @@ export function StatusPopoverV2() {
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown()} />
<Body shown={shown} />
</StatusPopoverBody>
),
}))
-1
View File
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory
const url = sdk().url
let container!: HTMLDivElement
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/servers"
import type { ServerConnection } from "@/context/server"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key
@@ -1,9 +1,9 @@
import { ServerConnection } from "@/context/servers"
import type { ServerConnection } from "@/context/server"
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
server: ServerConnection.Key
server?: ServerConnection.Key
directory: string
sessionIDs: string[]
}
@@ -19,15 +19,16 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
if (!("server" in detail) || typeof detail.server !== "string") return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
server: ServerConnection.Key.make(detail.server),
server:
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
directory: detail.directory,
sessionIDs,
}
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal, useServerCtx } from "@/context/global"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName, useServers } from "@/context/servers"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise"
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: SessionInfo | undefined
session: () => SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
onNavigate: () => void
active?: boolean
forceTruncate?: boolean
suppressNavigation?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
@@ -46,38 +46,41 @@ export function TabNavItem(props: {
event.stopPropagation()
props.onClose()
}
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
const project = createMemo(() => {
const session = props.session
const session = props.session()
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => {
const session = props.session
const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle
})
const projectName = createMemo(() => {
const session = props.session
const session = props.session()
if (!session) return
return displayName(project() ?? { worktree: session.location.directory })
})
const previewPath = createMemo(() => {
const session = props.session
const session = props.session()
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (servers.list.length <= 1) return
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
if (global.servers.list().length <= 1) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
@@ -118,7 +121,7 @@ export function TabNavItem(props: {
const closeRename = async (save: boolean) => {
if (rename.isPending || !editing()) return
const original = props.session?.title ?? ""
const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0
@@ -143,7 +146,7 @@ export function TabNavItem(props: {
event.preventDefault()
event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session
const session = props.session()
if (!session) return
titleEl.textContent = session.title ?? ""
setEditing(true)
@@ -210,7 +213,7 @@ export function TabNavItem(props: {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
@@ -218,14 +221,14 @@ export function TabNavItem(props: {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session}
when={props.session()}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
@@ -248,7 +251,6 @@ export function TabNavItem(props: {
}}
data-slot="tab-title"
data-titlebar-tab-title
dir="auto"
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
@@ -265,7 +267,7 @@ export function TabNavItem(props: {
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session?.title ?? ""
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
@@ -306,7 +308,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session?.title,
title: props.session()?.title,
path: previewPath(),
serverName: serverLabel(),
}}
@@ -321,7 +323,7 @@ export function DraftTabItem(props: {
active?: boolean
onNavigate: () => void
onClose: () => void
suppressNavigation?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
@@ -364,14 +366,14 @@ export function DraftTabItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/servers"
import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
import { useGlobal, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
function SessionTabSlot(props: {
tab: SessionTab
id: string
index: number
active: boolean
index: () => number
active: () => boolean
forceTruncate: boolean
session: SessionInfo | undefined
session: () => SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
return props.id
},
get index() {
return props.index
return props.index()
},
})
let ref!: HTMLDivElement
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<TabNavItem
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
function SessionTabEntry(props: {
tab: SessionTab
id: string
index: number
active: boolean
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: ServerCtx | undefined
serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx
const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx
const ctx = props.serverCtx()
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => {
const ctx = props.serverCtx
const ctx = props.serverCtx()
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
session={session()}
session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename}
onNavigate={props.onNavigate}
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: number
active: boolean
index: () => number
active: () => boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
return props.id
},
get index() {
return props.index
return props.index()
},
})
let ref!: HTMLDivElement
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
export function TitlebarTabStrip(props: {
tabs: Tab[]
currentTab: Tab | undefined
currentTab: () => Tab | undefined
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
])
function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab
const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
@@ -339,9 +339,10 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = useServerCtx(() => {
const serverCtx = createMemo(() => {
if (tab.type !== "session") return
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
})
if (tab.type === "session") {
@@ -349,10 +350,10 @@ export function TitlebarTabStrip(props: {
<SessionTabEntry
tab={tab}
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
index={visibleIndex}
active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate}
serverCtx={serverCtx()}
serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => {
ref = element
@@ -367,8 +368,8 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot
tab={tab}
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
index={visibleIndex}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
+230 -48
View File
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal, useServerCtx } from "@/context/global"
import { ServerConnection, useServers } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = {
version: string | undefined
installing: boolean
version: () => string | undefined
installing: () => boolean
install: () => void
}
@@ -61,13 +61,15 @@ export function useTitlebarRightMount() {
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const servers = useServers()
const server = useServer()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
@@ -75,9 +77,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
@@ -93,6 +97,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})
const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false
if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
return parts.at(-1) === "session"
})
createEffect(() => {
const current = path()
@@ -104,9 +116,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})
})
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing ?? false
const version = props.update?.version
const installing = props.update?.installing() ?? false
const version = props.update?.version()
return {
visible: version !== undefined || installing,
installing,
@@ -172,7 +188,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
data-tauri-drag-region
>
<Switch>
<Match when>
<Match when={useV2Titlebar()}>
{(_) => {
const layout = useLayout()
const global = useGlobal()
@@ -184,7 +200,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
() => {
const route = layout.route()
if (route.type !== "session") return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
@@ -227,7 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
const next = { server: route.server, sessionId }
const next = { server: route.server ?? server.key, sessionId }
tabsStoreActions.addSessionTab(next)
}
})
@@ -240,43 +258,53 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const openNewTab = () => {
const route = layout.route()
switch (route.type) {
case "session": {
const activeSession = session()
if (!activeSession) return
const sessionTab = {
type: "session" as const,
server: route.server,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
const activeSession = session()
if (route.type === "session" && activeSession) {
const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
case "draft": {
const activeTab = currentTab()
if (activeTab?.type !== "draft") return
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
}
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
const activeTab = currentTab()
if (activeTab?.type === "draft") {
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
if (route.type === "home") {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
case "home": {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.ensureServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
@@ -364,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
currentTab={currentTab}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
@@ -402,6 +430,158 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
)
}}
</Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !macTrafficLights(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={800}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
>
<IconV2 name="edit" size="small" />
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>
</div>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
</Show>
</div>
</div>
</Match>
</Switch>
</header>
)
@@ -477,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
}
return (
<Show when={["local", "beta", "dev"].includes(channel)}>
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
</Show>
<>
{["local", "beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
)}
</>
)
}
@@ -1,4 +1,4 @@
import { For, Show, type JSX } from "solid-js"
import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<Show
when={props.variant === "v2"}
fallback={
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
}
>
{props.variant === "v2" ? (
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
@@ -78,31 +65,39 @@ export function WindowsAppMenu(props: {
onKeyDown={rememberFocus}
/>
</div>
</Show>
) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
{(menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
{(entry) => {
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
if (entry.type === "separator") return <DropdownMenu.Separator />
return (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
)
}}
</For>
</DesktopMenuSubmenu>
)}
</For>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
+1 -1
View File
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(
serverSDK.scope,
serverSDK().scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
+4 -4
View File
@@ -66,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
)
const inflight = new Map<string, Promise<void>>()
@@ -123,7 +123,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = createFileViewCache(serverSDK.scope)
const viewCache = createFileViewCache(serverSDK().scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {
@@ -211,8 +211,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK.api.file
.find(
serverSDK()
.api.file.find(
{
location: { directory: sdk().directory },
query,
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: ".git/index.lock",
event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return
if (event.type !== "file.watcher.updated") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
@@ -14,7 +14,6 @@ import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
@@ -107,58 +106,16 @@ describe("query keys", () => {
})
test("loads projects from the current endpoint", async () => {
const calls: string[] = []
const projects = {
const api = {
list: async () => [
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
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/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
])
})
test("loads references from the current location-scoped endpoint", async () => {
+121 -163
View File
@@ -6,8 +6,6 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput,
LocationGetOutput,
PermissionRequest,
@@ -26,16 +24,15 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types"
import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session"
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
import { sameDirectory } from "@/utils/workspace"
type GlobalStore = {
ready: boolean
@@ -107,7 +104,6 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"]
@@ -115,35 +111,15 @@ type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ projectID: project.id })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
@@ -152,11 +128,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
})
export async function bootstrapGlobal(input: {
serverAPI: CatalogApi & {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@@ -170,7 +142,7 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -249,10 +221,6 @@ type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
}
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
}
@@ -263,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
})
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
queryOptions({
queryKey: [scope, directory, "integrations"] as const,
queryFn: () =>
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
})
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data))
@@ -311,6 +272,7 @@ export async function bootstrapDirectory(input: {
}
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
global: {
@@ -335,109 +297,104 @@ export async function bootstrapDirectory(input: {
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
.catch((err) => {
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
@@ -445,19 +402,20 @@ export async function bootstrapDirectory(input: {
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[]
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
@@ -8,7 +8,6 @@ import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
store[1],
@@ -59,17 +58,11 @@ beforeAll(async () => {
get isLoading() {
return options().queryKey?.[1] === "path"
},
get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
},
get isRefetchError() {
return false
},
get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
if (options().queryKey?.[1] === "providers") return provider
return undefined
},
}
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -183,37 +173,6 @@ describe("createChildStoreManager", () => {
}
})
test("writes refreshed VCS data to the child store", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
manager.vcs("/project", { branch: "feature", default_branch: "main" })
expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" })
} finally {
dispose()
}
})
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("does not mark a cancelled provider query as ready", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
providerQuerySuccess = false
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/cancelled")
expect(store.provider_ready).toBe(false)
} finally {
providerQuerySuccess = true
dispose()
}
})
test("does not enable location queries before the event handshake", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
let connected = false
const offset = querySingles.length
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => connected,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
manager.child("/handshake")
const queries = querySingles.slice(offset)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
connected = true
expect(queries[0]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
} finally {
dispose()
}
})
})
@@ -1,4 +1,4 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types"
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
owner: Owner
connected: Accessor<boolean>
scope: ServerScope
persist: typeof persisted
isBooting: (directory: string) => boolean
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({
...input.queryOptions.path(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const mcpQuery = useQuery(() => ({
...input.queryOptions.mcp(key),
enabled: input.connected() && mcpEnabled(),
}))
const mcpResourceQuery = useQuery(() => ({
...input.queryOptions.mcpResources(key),
enabled: input.connected() && mcpEnabled(),
}))
const lspQuery = useQuery(() => ({
...input.queryOptions.lsp(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: input.connected() && instanceQueriesEnabled(),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
const provider = providerQuery.data
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return provider
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {},
get path() {
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
setStore("icon", value)
}
function vcs(directory: string, value: VcsInfo) {
const key = directoryKey(directory)
const child = ensureChild(directory)
const cached = vcsCache.get(key)
if (!cached) return
cached.setStore("value", value)
child[1]("vcs", value)
}
return {
children,
ensureChild,
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
peek,
projectMeta,
projectIcon,
vcs,
mark,
pin,
unpin,
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1)
})
test("leaves server.connected refresh to the connection sync", () => {
test("handles server.connected by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
setGlobalProject() {},
})
expect(refreshCount).toBe(0)
expect(refreshCount).toBe(1)
})
})
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type {
@@ -38,10 +38,11 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
@@ -187,10 +188,10 @@ export function applyDirectoryEvent(input: {
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "worktree.resolved": {
case "project.directory.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = Worktree.adopt(
const adopted = ProjectDirectories.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -34,7 +34,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
@@ -45,7 +44,6 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" },
}
@@ -59,7 +57,6 @@ describe("app session cache", () => {
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
@@ -73,7 +70,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},
@@ -84,7 +80,6 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
form: {},
part_text_accum_delta: {},
}
@@ -1,5 +1,5 @@
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -14,7 +14,6 @@ type SessionCache = {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
@@ -39,7 +38,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
if (store.form) delete store.form[sessionID]
}
}
@@ -124,11 +124,9 @@ export function normalizeProviderList(
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
const worktree = "canonical" in project ? project.canonical : project.worktree
return {
...project,
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
worktree: "canonical" in project ? project.canonical : project.worktree,
vcs: project.vcs === "git" ? "git" : undefined,
}
}
+28 -40
View File
@@ -1,21 +1,19 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Accessor, createEffect, createMemo, createRoot } from "solid-js"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { createServerData } from "@opencode-ai/client/solid"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { createServerPermissionState } from "./permission"
import { createServerNotificationState } from "./notification"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServers()
const server = useServer()
const serverHealth = useServerHealth(
() => server.list,
() => true,
@@ -37,21 +35,23 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
>()
const owner = getOwner()
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
if (existing) return existing.serverCtx
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, serverCtx)
return serverCtx
serverCtxs.set(key, root)
return root.serverCtx
}
createMemo(() => {
@@ -63,8 +63,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
const { dispose } = serverCtxs.get(key)!
dispose()
serverCtxs.delete(key)
}
}
@@ -93,22 +93,22 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
function createServerController(
function createServerCtx(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const connKey = ServerConnection.key(conn)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
const permission = createServerPermissionState({ sdk, sync })
const notification = createServerNotificationState({ sdk, sync, key: connKey })
const data = createServerData({
api: () => sdk.api,
event: sdk.event,
connection: sdk.connection,
directory: "",
})
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -141,7 +141,7 @@ function createServerController(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
data,
queryClient,
sdk,
sync,
isLocal,
@@ -150,22 +150,10 @@ function createServerController(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
permission,
notification,
}
}
export function useServerCtx(server: Accessor<ServerConnection.Any>): Accessor<ServerCtx>
export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>): Accessor<ServerCtx | undefined>
export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>) {
const global = useGlobal()
return () => {
const s = server()
if (s) return global.ensureServerCtx(s)
}
}
export type ServerCtx = ReturnType<typeof createServerController>
export type ServerCtx = ReturnType<typeof createServerCtx>
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
+12 -32
View File
@@ -2,12 +2,7 @@ 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 UiI18nPluralLookupKey,
type UiI18nPluralKey,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { pluralCategory, type UiI18nPluralKey } 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"
@@ -25,7 +20,7 @@ import {
export type Locale = DesktopNativeLocale
export type Direction = "ltr" | "rtl"
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "he", "ur", "pa", "fa", "dv"])
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
function localeDirection(locale: Locale): Direction {
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
@@ -33,17 +28,11 @@ function localeDirection(locale: Locale): Direction {
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
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 Extract<keyof Dictionary, string>> = Key extends
| AppI18nPluralLookupKey
| UiI18nPluralLookupKey
? never
: Key
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
@@ -73,7 +62,6 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
he: () => merge(import("@/i18n/he"), import("@opencode-ai/ui/i18n/he")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
@@ -201,25 +189,18 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <
Key extends Extract<keyof Dictionary, string>,
>(
key: TranslationKey<Key>,
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
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)
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
}
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]
@@ -250,7 +231,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label,
t,
plural,
pluralForm,
setLocale(next: Locale) {
setStore("locale", normalizeLocale(next))
},
+247 -103
View File
@@ -4,10 +4,13 @@ import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { ServerConnection, useServers } from "./servers"
import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import type { Project } from "@/types"
import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -16,6 +19,7 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -71,6 +75,13 @@ type SessionView = {
todoCollapsed?: boolean
}
type TabHandoff = {
scope: ServerScope
dir: string
id: string
at: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
@@ -80,8 +91,9 @@ export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
| { type: "home" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
@@ -136,23 +148,34 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
if (parts[1] === "session") return { type: "home" }
throw new Error("Unrecognised route!")
}
if (parts[1] !== "session") return { type: "home" }
export const useCurrentRoute = () => {
const location = useLocation()
return createMemo(() => currentRoute(location.pathname, location.search))
const id = parts[2]
if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
init: () => {
// const serverSync = useServerSync()
const servers = useServers()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
if (value.server) return value
if (value.type === "draft") {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
if (draft) return { ...value, server: draft.server }
}
return { ...value, server: server.key }
})
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -244,8 +267,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout", ["layout.v6"]), migrate },
{ ...target, migrate },
createStore({
sidebar: {
opened: false,
@@ -274,8 +298,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
handoff: {
tabs: undefined as TabHandoff | undefined,
},
home: {
selection: { server: ServerConnection.key(servers.list[0]) } as HomeProjectSelection,
selection: { server: server.key } as HomeProjectSelection,
},
}),
)
@@ -415,116 +442,168 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return available[Math.floor(Math.random() * available.length)]
}
// function enrich(project: { worktree: string; expanded: boolean }) {
// const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
// const projectID = childStore.project
// const metadata = projectID
// ? serverSync.data.project.find((x) => x.id === projectID)
// : serverSync.data.project.find((x) => x.worktree === project.worktree)
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? serverSync().data.project.find((x) => x.id === projectID)
: serverSync().data.project.find((x) => x.worktree === project.worktree)
// // Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// // Without this, different subdirectories of the same git repo would share the same
// // icon from the database instead of using their individual overrides.
// const base = { ...metadata, ...project }
// if (childStore.icon) {
// return { ...base, icon: { ...base.icon, override: childStore.icon } }
// }
// return base
// }
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
}
// const roots = createMemo(() => {
// const map = new Map<string, string>()
// for (const project of serverSync.data.project) {
// const sandboxes = project.sandboxes ?? []
// for (const sandbox of sandboxes) {
// map.set(sandbox, project.worktree)
// }
// }
// return map
// })
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync().data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
}
}
return map
})
// const rootFor = (directory: string) => {
// const map = roots()
// if (map.size === 0) return directory
const rootFor = (directory: string) => {
const map = roots()
if (map.size === 0) return directory
// const visited = new Set<string>()
// const chain = [directory]
const visited = new Set<string>()
const chain = [directory]
// while (chain.length) {
// const current = chain[chain.length - 1]
// if (!current) return directory
while (chain.length) {
const current = chain[chain.length - 1]
if (!current) return directory
// const next = map.get(current)
// if (!next) return current
const next = map.get(current)
if (!next) return current
// if (visited.has(next)) return directory
// visited.add(next)
// chain.push(next)
// }
if (visited.has(next)) return directory
visited.add(next)
chain.push(next)
}
// return directory
// }
return directory
}
const enriched = createMemo(() => [] /* servers.projects.list().map(enrich) */)
createEffect(() => {
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
// createEffect(() => {
// const projects = enriched()
// if (projects.length === 0) return
// if (!serverSync.ready) return
batch(() => {
for (const project of projects) {
const root = rootFor(project.worktree)
if (root === project.worktree) continue
// for (const project of projects) {
// if (!project.id) continue
// if (project.id === "global") continue
// serverSync.project.icon(project.worktree, project.icon?.override)
// }
// })
server.projects.remove(project.worktree)
// createEffect(() => {
// const projects = enriched()
// if (projects.length === 0) return
if (!seen.has(root)) {
server.projects.open(root)
seen.add(root)
}
// for (const project of projects) {
// if (project.icon?.color) colorRequested.delete(project.worktree)
// }
if (project.expanded) server.projects.expand(root)
}
})
})
// const used = new Set<string>()
// for (const project of projects) {
// const color = project.icon?.color ?? colors[project.worktree]
// if (color) used.add(color)
// }
const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
return projects.map((project) => {
const color = project.icon?.color ?? colors[project.worktree]
if (!color) return project
const icon = project.icon ? { ...project.icon, color } : { color }
return { ...project, icon }
})
})
// for (const project of projects) {
// if (project.icon?.color || project.icon?.override || project.icon?.url) continue
// const worktree = project.worktree
// const existing = colors[worktree]
// const color = existing ?? pickAvailableColor(used)
// if (!existing) {
// used.add(color)
// setColors(worktree, color)
// }
// if (!project.id) continue
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!serverSync().ready) return
// const requested = colorRequested.get(worktree)
// if (requested === color) continue
// colorRequested.set(worktree, color)
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
serverSync().project.icon(project.worktree, project.icon?.override)
}
})
// if (project.id === "global") {
// serverSync.project.meta(worktree, { icon: { color } })
// continue
// }
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
// const projectID = project.id
// void (async () => {
// // TODO: Restore project color updates when the V2 client exposes a project update API.
// void projectID
// })().catch(() => {
// if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
// })
// }
// })
for (const project of projects) {
if (project.icon?.color) colorRequested.delete(project.worktree)
}
const used = new Set<string>()
for (const project of projects) {
const color = project.icon?.color ?? colors[project.worktree]
if (color) used.add(color)
}
for (const project of projects) {
if (project.icon?.color || project.icon?.override || project.icon?.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
if (!existing) {
used.add(color)
setColors(worktree, color)
}
if (!project.id) continue
const requested = colorRequested.get(worktree)
if (requested === color) continue
colorRequested.set(worktree, color)
if (project.id === "global") {
serverSync().project.meta(worktree, { icon: { color } })
continue
}
const projectID = project.id
void (async () => {
// TODO: Restore project color updates when the V2 client exposes a project update API.
void projectID
})().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync().project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
return {
route: useCurrentRoute(),
route,
ready,
home: {
selection: createMemo(() => store.home.selection),
@@ -532,6 +611,71 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("home", "selection", reconcile(selection))
},
},
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
projects: {
list,
recentlyClosed: createMemo(() => {
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
return server.projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
}),
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync().project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
server.projects.close(directory)
},
expand(directory: string) {
server.projects.expand(directory)
},
collapse(directory: string) {
server.projects.collapse(directory)
},
move(directory: string, toIndex: number) {
server.projects.move(directory, toIndex)
},
},
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
close() {
setStore("sidebar", "opened", false)
},
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
},
toggleWorkspaces(directory: string) {
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
setStore("sidebar", "workspaces", directory, !current)
},
},
terminal: {
height: createMemo(() => store.terminal.height),
resize(height: number) {
+5 -5
View File
@@ -73,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection", ["model-selection.v1"]),
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
@@ -127,14 +127,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session))
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(serverSDK.scope, sdk().directory, session)
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@@ -385,7 +385,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
const key = handoffKey(serverSDK.scope, dir, session)
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
if (dir === sdk().directory) {
@@ -400,7 +400,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, {
agent: msg.agent,

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