mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1b6cbc6a0 | |||
| 7e94ddafe8 | |||
| 7fe993879f | |||
| def14d96ef | |||
| b0b1149233 | |||
| 7becbbf711 | |||
| 87481f2bb6 | |||
| 9535a8f929 | |||
| e917c12c80 | |||
| 89130db6b0 | |||
| 9465a6c97d | |||
| 53fd02970d | |||
| 6ff0adef22 | |||
| 3307f99f1f | |||
| 8de7a3c739 | |||
| 28d0637406 | |||
| ef7f712867 | |||
| 3016830e25 | |||
| e9e7472456 | |||
| 9f9fbf815c | |||
| c88facb2cc | |||
| d4b85f8d83 | |||
| 1882c33827 | |||
| 01624c8c87 | |||
| 0891ecd8e0 | |||
| 32f278b48f | |||
| bb6bec9070 | |||
| 0eac0964af | |||
| f67e80c275 | |||
| 19231fce4b | |||
| d0a7ca6d1b | |||
| d80e86be9d | |||
| e4bd9757a3 | |||
| 4087cf1a97 | |||
| 14f0bf64a1 | |||
| 2df47ee394 | |||
| ad82616445 | |||
| d4ad650f73 | |||
| 2039c90c06 | |||
| da59457ca4 | |||
| db4dbaa289 | |||
| d3f30df180 | |||
| 7ea343cabd | |||
| e024e2ef92 | |||
| ceb4890ca3 | |||
| 3f239b3834 | |||
| c1ee3c6e34 | |||
| a1ab489e61 | |||
| 8c38d260eb | |||
| a4f25a94b4 | |||
| ff0382e971 | |||
| ac5380614f | |||
| c08350b83e | |||
| c5cf416633 | |||
| 3dbce3dade | |||
| a277557a5f | |||
| 005bf088c9 | |||
| eca5e68a5e | |||
| 5b11635d97 | |||
| a9eda2e99e | |||
| 73009f0b09 | |||
| f720490219 | |||
| 341c64cc97 | |||
| 1e17856ba4 | |||
| a6f7fe739c | |||
| 02f50a6e80 | |||
| 7565e03536 | |||
| 035a5a1a67 | |||
| e8b0992788 | |||
| 8cbea4fbb7 | |||
| f256a4c538 | |||
| a45c2b917e | |||
| f28d72d15e | |||
| d8a9d8a76d | |||
| 982a9044c5 | |||
| 7edefb3347 | |||
| 017a5977d2 | |||
| 7336cc5a7b | |||
| e1587588c4 | |||
| be93cec9ba | |||
| 7c898249a2 | |||
| 9e432a6785 | |||
| 1ead8d84a2 | |||
| 8c81f9a40b | |||
| 95636bd3ca | |||
| 7dae9a1083 | |||
| d46be565aa | |||
| 8021dbd80f | |||
| c8487bac54 | |||
| 484f00ebf4 | |||
| c3be6c4965 | |||
| 172d08cb98 | |||
| 5e39051183 | |||
| d50b9e8f9a | |||
| 921b1c6a34 | |||
| 9c8060d96d | |||
| 87db7a7f0a | |||
| 237e694df0 | |||
| 3f9dad3fd0 | |||
| 3cc70160de | |||
| b72d7b6fa2 | |||
| 029f5aa3a0 | |||
| 284e97068c | |||
| 970c38257c | |||
| c39ad38424 | |||
| 91508f6c88 | |||
| 1274408b89 | |||
| 7ae1be5bf6 | |||
| 40e4d730ca | |||
| 8dc539b4ae | |||
| 8e8d38c3cd | |||
| a9bebd3653 | |||
| 78f5764319 | |||
| 55b0211690 | |||
| 4f62295e44 | |||
| e1c41941a9 | |||
| 0b4edfc64c | |||
| 4469cecc4e | |||
| b06768aa8e | |||
| bc2d3df05f | |||
| 35075bb466 | |||
| a9afed0515 | |||
| 09afffeead | |||
| de78da8a23 | |||
| 9ca2b44235 | |||
| 7ffc22c0ef | |||
| 759f6afa80 | |||
| 1ad63cfc6c | |||
| 7d8195e8fe | |||
| 7534d23551 | |||
| 0a6637e17a | |||
| 2b2b69d668 | |||
| 9e8b2171a5 | |||
| 5e2a6257b2 | |||
| 065dc274ec | |||
| a85d8d23aa | |||
| 2b2aacc939 | |||
| 7840562d1b | |||
| 53669cab2b | |||
| 4b19ea2a71 | |||
| 553b42fcc7 | |||
| e62b09e6fc | |||
| f51665191a | |||
| e63996919b | |||
| 909db63265 | |||
| 66495a2a22 | |||
| 9ba82a1b8c | |||
| b62806683e | |||
| ae4be983cb | |||
| ad78ef5a4c | |||
| 67a04787bf | |||
| aaa42fe3bf | |||
| 3337495427 | |||
| 80a4fe8f39 | |||
| 91ed2567ef | |||
| c4545ab12f | |||
| 57ddfeb756 | |||
| ce7f54d5e7 | |||
| bce2992729 | |||
| 3819848cf2 | |||
| 55f4a2691a | |||
| a48912cbb1 | |||
| 2ea4bb793e | |||
| d07323ef59 | |||
| 5ce89dc2ad | |||
| 589ef16128 | |||
| 386afb77e0 | |||
| 29af2e39ff | |||
| 090a26a301 | |||
| ce9a875181 | |||
| db88c42335 | |||
| adba484df4 | |||
| 37c263e153 | |||
| 204f48de8b | |||
| 743f6410f2 | |||
| 20589d66d5 | |||
| 62e4641235 | |||
| e59ba24b80 | |||
| 347510a73b | |||
| d03e0c5e54 | |||
| 84c79c1399 | |||
| e45210c6d2 | |||
| 92cede0541 | |||
| fada1a538f | |||
| 542ba88602 | |||
| 411eff73f0 | |||
| 50eee1f5a4 | |||
| 0a601cf334 | |||
| c9db6e9a1f | |||
| 130038eb63 | |||
| 4438f69aac | |||
| 76ced5418f | |||
| c40e3e74a7 | |||
| 0317531906 | |||
| 5f241f1cc1 | |||
| b513fafe83 | |||
| 21c4c93770 | |||
| abc4b83f58 | |||
| cb562b2c62 | |||
| 2d2339d37d | |||
| 849c2598ab | |||
| 3033afba51 | |||
| 5a8ee27254 | |||
| 4cc022481c | |||
| d36a2d8981 | |||
| 96ff82a11f | |||
| 4872c48c23 | |||
| 47fc6f2991 | |||
| da312b009c | |||
| 70535a1bad | |||
| 43e472bba7 | |||
| b67fda133a | |||
| e2531dff0f | |||
| cc3615b535 | |||
| 5542415b64 | |||
| 8bec40a816 | |||
| 4a81e8392b | |||
| a19b52e85b | |||
| 9105f35966 | |||
| 3ba3954aa0 | |||
| 3fc5af6dd1 | |||
| 5586f9675e | |||
| 3b719aba48 | |||
| 20a3a2138e | |||
| cd46f22d51 | |||
| cfa134f042 | |||
| 61653a20a5 | |||
| 2a097f3af7 | |||
| ba4b8e21f4 | |||
| 7985c2066a | |||
| 67caf894e0 | |||
| 78587c141b | |||
| f5573281ca | |||
| 4817228f96 | |||
| 5a5117b36c | |||
| 6a288426b8 | |||
| 573ac85983 | |||
| 1221bbbe14 | |||
| 6bea0f4fa4 | |||
| cc34084dfe | |||
| b8142c7aa8 | |||
| ba3f966ed0 | |||
| b95fe7b2d7 | |||
| 9da9ed9de6 | |||
| fab2133129 | |||
| 901c9e7329 | |||
| 3476e6baa5 | |||
| 86e04d4174 | |||
| 45cd8d7692 | |||
| a46374e90e | |||
| 41405c9eb0 | |||
| 49d2dd8a38 | |||
| 0df2f6245a | |||
| 38989c57a0 | |||
| 69a80663a2 | |||
| 518b0bf9e3 | |||
| be08207a88 | |||
| efb6cc2d4b | |||
| 4bffbb655f | |||
| ba6cf38607 | |||
| 86978e7a8c | |||
| 82a3270cf2 | |||
| c0a258b22a | |||
| ed926be253 | |||
| b527f605d9 | |||
| aca52ba198 | |||
| f7d5a1cbc7 | |||
| 3238daa851 | |||
| 90291fc6df | |||
| a414e06d0c | |||
| 52f65f3a0b | |||
| 3a1c6df9e2 | |||
| 4436678f7d | |||
| 08fb473735 | |||
| 51213520f5 | |||
| 0bc9a28b5e | |||
| 1d2a7b4c86 | |||
| 453b61e27b | |||
| 95ebf50ace | |||
| d8a1b932eb | |||
| 0c91e6a620 | |||
| ef3b673084 | |||
| c69abee0c7 | |||
| dc3ff4b7aa | |||
| 958e08e109 | |||
| 590a11ed98 | |||
| 17544802c3 | |||
| 4a760b5743 | |||
| 888c4cb504 | |||
| 4394b324c9 | |||
| a27ffb20f0 | |||
| 81141dc562 | |||
| 285d315b4e | |||
| 656f299017 |
@@ -219,7 +219,6 @@ jobs:
|
|||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
@@ -316,10 +315,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
@@ -343,8 +339,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
CSC_KEYCHAIN: build.keychain
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: translate English to other languages
|
description: translate English to other languages
|
||||||
model: opencode/claude-opus-4-8
|
model: opencode/gpt-5.6-sol
|
||||||
---
|
---
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||||
|
|||||||
@@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
|
|||||||
|
|
||||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||||
|
|
||||||
## Trust & Vouch System
|
|
||||||
|
|
||||||
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
|
|
||||||
- **Vouched users** are explicitly trusted contributors.
|
|
||||||
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
|
|
||||||
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
|
|
||||||
|
|
||||||
### For maintainers
|
|
||||||
|
|
||||||
Collaborators with write access can manage the vouch list by commenting on any issue:
|
|
||||||
|
|
||||||
- `vouch` — vouch for the issue author
|
|
||||||
- `vouch @username` — vouch for a specific user
|
|
||||||
- `denounce` — denounce the issue author
|
|
||||||
- `denounce @username` — denounce a specific user
|
|
||||||
- `denounce @username <reason>` — denounce with a reason
|
|
||||||
- `unvouch` / `unvouch @username` — remove someone from the list
|
|
||||||
|
|
||||||
Changes are committed automatically to `.github/VOUCHED.td`.
|
|
||||||
|
|
||||||
### Denouncement policy
|
|
||||||
|
|
||||||
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
|
|
||||||
|
|
||||||
## Issue Requirements
|
## Issue Requirements
|
||||||
|
|
||||||
All issues **must** use one of our issue templates:
|
All issues **must** use one of our issue templates:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -193,7 +194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -220,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -242,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -266,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -286,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -302,7 +303,7 @@
|
|||||||
"@ai-sdk/google": "3.0.73",
|
"@ai-sdk/google": "3.0.73",
|
||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.51",
|
||||||
"@ai-sdk/openai": "3.0.84",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
@@ -338,7 +339,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.11.1",
|
"gitlab-ai-provider": "6.12.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -380,16 +381,16 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "11.0.2",
|
"electron-store": "11.0.2",
|
||||||
"electron-updater": "6.8.9",
|
"electron-updater": "6.8.9",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"marked": "^15",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
@@ -434,7 +435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -448,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -460,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -492,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -508,7 +509,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -539,7 +540,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -558,7 +559,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -577,7 +578,7 @@
|
|||||||
"@ai-sdk/google": "3.0.73",
|
"@ai-sdk/google": "3.0.73",
|
||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.51",
|
||||||
"@ai-sdk/openai": "3.0.84",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
@@ -631,7 +632,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.11.1",
|
"gitlab-ai-provider": "6.12.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -689,7 +690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -706,9 +707,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.4.3",
|
"@opentui/core": ">=0.4.5",
|
||||||
"@opentui/keymap": ">=0.4.3",
|
"@opentui/keymap": ">=0.4.5",
|
||||||
"@opentui/solid": ">=0.4.3",
|
"@opentui/solid": ">=0.4.5",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -765,7 +766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -780,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -795,9 +796,10 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -809,7 +811,6 @@
|
|||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
@@ -839,7 +840,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -852,7 +853,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -886,7 +887,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -905,7 +906,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -937,6 +938,7 @@
|
|||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "18.0.25",
|
"@types/react": "18.0.25",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"storybook": "^10.2.13",
|
"storybook": "^10.2.13",
|
||||||
"storybook-solidjs-vite": "^10.0.9",
|
"storybook-solidjs-vite": "^10.0.9",
|
||||||
@@ -946,7 +948,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -973,7 +975,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -999,6 +1001,7 @@
|
|||||||
"remend": "catalog:",
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
|
"solid-sonner": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1024,7 +1027,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1073,11 +1076,13 @@
|
|||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -1099,9 +1104,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.4.3",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.4.3",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.4.3",
|
"@opentui/solid": "0.4.5",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.2.10",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -1141,6 +1146,7 @@
|
|||||||
"shiki": "4.2.0",
|
"shiki": "4.2.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
"solid-sonner": "0.3.1",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
@@ -1198,7 +1204,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||||
|
|
||||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||||
|
|
||||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||||
|
|
||||||
@@ -2038,27 +2044,27 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
|
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
|
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
|
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||||
|
|
||||||
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
|
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
|
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -3844,13 +3850,13 @@
|
|||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
|
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="],
|
||||||
|
|
||||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
|
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||||
|
|
||||||
@@ -5122,6 +5128,8 @@
|
|||||||
|
|
||||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||||
|
|
||||||
|
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
|
||||||
|
|
||||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||||
|
|
||||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||||
@@ -5698,7 +5706,9 @@
|
|||||||
|
|
||||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||||
|
|
||||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
@@ -6024,6 +6034,8 @@
|
|||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
@@ -6032,8 +6044,6 @@
|
|||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
|
||||||
|
|
||||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||||
|
|
||||||
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||||
@@ -6042,6 +6052,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
|
||||||
|
|
||||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||||
|
|
||||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||||
@@ -6172,6 +6184,8 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
||||||
@@ -6988,6 +7002,8 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
@@ -7414,6 +7430,8 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+6
-2
@@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
server: {
|
server: {
|
||||||
placement: { region: "aws:us-east-2" },
|
placement: { region: "aws:us-east-2" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: (args) => {
|
||||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
|
||||||
|
...(flags ?? []),
|
||||||
|
"global_fetch_strictly_public",
|
||||||
|
])
|
||||||
|
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
|||||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
export const deployAws = $app.stage === awsStage
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
|
if ($app.stage === "production") {
|
||||||
|
new cloudflare.DnsRecord("TrustCenter", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "trust.opencode.ai",
|
||||||
|
type: "CNAME",
|
||||||
|
content: "3a69a5bb27875189.vercel-dns-016.com",
|
||||||
|
proxied: false,
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
new cloudflare.DnsRecord("TrustCenterVerification", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "opencode.ai",
|
||||||
|
type: "TXT",
|
||||||
|
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
regionKey: "us",
|
regionKey: "us",
|
||||||
|
|||||||
+66
-33
@@ -8,6 +8,8 @@
|
|||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
autoPatchelfHook,
|
autoPatchelfHook,
|
||||||
|
copyDesktopItems,
|
||||||
|
makeDesktopItem,
|
||||||
opencode,
|
opencode,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
nodejs
|
nodejs
|
||||||
makeWrapper
|
makeWrapper
|
||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
copyDesktopItems
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
darwin.autoSignDarwinBinariesHook
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
(lib.getLib stdenv.cc.cc)
|
(lib.getLib stdenv.cc.cc)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
|
||||||
|
name = "ai.opencode.desktop";
|
||||||
|
desktopName = "OpenCode";
|
||||||
|
exec = "opencode-desktop %U";
|
||||||
|
icon = "ai.opencode.desktop";
|
||||||
|
# Electron 41 derives X11 WM_CLASS from app.name.
|
||||||
|
startupWMClass = "OpenCode";
|
||||||
|
categories = [ "Development" ];
|
||||||
|
});
|
||||||
|
|
||||||
env = opencode.env // {
|
env = opencode.env // {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
# https://github.com/electron/electron/issues/31121
|
postPatch =
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||||
postPatch = lib.optionalString stdenv.isLinux ''
|
''
|
||||||
BASE_PATH=packages/desktop
|
substituteInPlace packages/script/src/index.ts \
|
||||||
FILES=(src/main/windows.ts)
|
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||||
for file in "''${FILES[@]}"; do
|
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||||
substituteInPlace $BASE_PATH/$file \
|
''
|
||||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
# https://github.com/electron/electron/issues/31121
|
||||||
done
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
'';
|
+ lib.optionalString stdenv.isLinux ''
|
||||||
|
BASE_PATH=packages/desktop
|
||||||
|
FILES=(src/main/windows.ts)
|
||||||
|
for file in "''${FILES[@]}"; do
|
||||||
|
substituteInPlace $BASE_PATH/$file \
|
||||||
|
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||||
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
runHook preInstall
|
||||||
runHook preInstall
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
mkdir -p $out/Applications
|
||||||
mkdir -p $out/Applications
|
mv dist/mac*/*.app $out/Applications
|
||||||
mv dist/mac*/*.app $out/Applications
|
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
mkdir -p $out/opt/opencode-desktop
|
||||||
mkdir -p $out/opt/opencode-desktop
|
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
install -Dm644 resources/icons/32x32.png \
|
||||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||||
--inherit-argv0 \
|
install -Dm644 resources/icons/64x64.png \
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
install -Dm644 resources/icons/128x128.png \
|
||||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
||||||
''
|
install -Dm644 resources/icons/128x128@2x.png \
|
||||||
+ ''
|
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
||||||
runHook postInstall
|
install -Dm644 resources/icons/icon.png \
|
||||||
'';
|
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
||||||
|
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
||||||
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
|
--inherit-argv0 \
|
||||||
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
|
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||||
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||||
|
''
|
||||||
|
+ ''
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
autoPatchelfIgnoreMissingDeps = [
|
autoPatchelfIgnoreMissingDeps = [
|
||||||
"libc.musl-x86_64.so.1"
|
"libc.musl-x86_64.so.1"
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
|
||||||
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
|
||||||
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
|
||||||
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -40,9 +40,9 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.4.3",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.4.3",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.4.3",
|
"@opentui/solid": "0.4.5",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
"@sentry/vite-plugin": "4.6.0",
|
"@sentry/vite-plugin": "4.6.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
|
"solid-sonner": "0.3.1",
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"@lydell/node-pty": "1.2.0-beta.12"
|
"@lydell/node-pty": "1.2.0-beta.12"
|
||||||
}
|
}
|
||||||
@@ -143,12 +144,14 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# V1 API Migration Checklist
|
||||||
|
|
||||||
|
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||||
|
- `src/context/server-sdk.tsx`
|
||||||
|
- [x] Reduce current granular session and message events into the existing app projections.
|
||||||
|
- `src/context/server-session-v2-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- `src/context/notification.tsx`
|
||||||
|
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||||
|
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [x] Adapt current permission and question events to the existing request model.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- [x] Consume current file watcher events.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- [x] Consume current VCS events.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Consume current `pty.exited` events.
|
||||||
|
- `src/context/terminal.tsx`
|
||||||
|
- [ ] Migrate LSP and reference events.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate session listing from `GET /session`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- `src/context/directory-sync.ts`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||||
|
- `src/components/titlebar.tsx`
|
||||||
|
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||||
|
- `src/context/directory-sync.ts`
|
||||||
|
- `src/context/layout.tsx`
|
||||||
|
- `src/pages/home.tsx`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- `src/components/titlebar-tab-nav.tsx`
|
||||||
|
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||||
|
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||||
|
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||||
|
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||||
|
- `src/components/dialog-fork.tsx`
|
||||||
|
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- Blocked: the current API has no sharing contract or implementation.
|
||||||
|
|
||||||
|
## Session Compatibility Fallbacks
|
||||||
|
|
||||||
|
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||||
|
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
|
||||||
|
## Filesystem
|
||||||
|
|
||||||
|
- [ ] Migrate file listing from `GET /file`.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- [ ] Migrate file reads from `GET /file/content`.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- `src/pages/session/review-tab.tsx`
|
||||||
|
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||||
|
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/components/dialog-select-directory.tsx`
|
||||||
|
- `src/components/dialog-select-directory-v2.tsx`
|
||||||
|
|
||||||
|
## Projects And Worktrees
|
||||||
|
|
||||||
|
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||||
|
- `src/context/layout.tsx`
|
||||||
|
- `src/components/edit-project.ts`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||||
|
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
|
||||||
|
## VCS
|
||||||
|
|
||||||
|
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
|
||||||
|
## Configuration And Authentication
|
||||||
|
|
||||||
|
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||||
|
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- `src/components/dialog-custom-provider.tsx`
|
||||||
|
- `src/components/settings-providers.tsx`
|
||||||
|
- `src/components/settings-v2/providers.tsx`
|
||||||
|
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- `src/components/settings-providers.tsx`
|
||||||
|
- `src/components/settings-v2/providers.tsx`
|
||||||
|
|
||||||
|
## Permissions And Questions
|
||||||
|
|
||||||
|
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- `src/pages/session/composer/session-composer-state.ts`
|
||||||
|
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||||
|
- `src/pages/session/composer/session-question-dock.tsx`
|
||||||
|
|
||||||
|
## Commands, MCP, LSP, And References
|
||||||
|
|
||||||
|
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [ ] Migrate LSP status from `GET /lsp`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||||
|
- `src/components/command-palette.ts`
|
||||||
|
- `src/components/dialog-command-palette-v2.tsx`
|
||||||
|
|
||||||
|
## PTY And Terminal
|
||||||
|
|
||||||
|
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||||
|
- `src/context/terminal.tsx`
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||||
|
- `src/components/settings-general.tsx`
|
||||||
|
- `src/components/settings-v2/general.tsx`
|
||||||
|
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
|
||||||
|
## Legacy Types And Adapters
|
||||||
|
|
||||||
|
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||||
|
|
||||||
|
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||||
|
- `src/utils/session.ts`
|
||||||
|
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||||
|
- `src/utils/session-message.ts`
|
||||||
|
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||||
|
- `src/context/global-sync/utils.ts`
|
||||||
|
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||||
|
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||||
|
- `package.json`
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||||
|
- `e2e/utils/mock-server.ts`
|
||||||
|
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||||
|
- `e2e/utils/sse-transport.ts`
|
||||||
|
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||||
|
- `e2e/performance/timeline-stability/fixture.ts`
|
||||||
|
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||||
@@ -97,6 +97,7 @@ export async function setupTimeline(
|
|||||||
locale?: string
|
locale?: string
|
||||||
deviceScaleFactor?: number
|
deviceScaleFactor?: number
|
||||||
seedHistory?: boolean
|
seedHistory?: boolean
|
||||||
|
protocol?: "v1" | "v2"
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
@@ -114,6 +115,7 @@ export async function setupTimeline(
|
|||||||
retry: input.eventRetry ?? 20,
|
retry: input.eventRetry ?? 20,
|
||||||
})
|
})
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: input.protocol,
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
|||||||
const messages = [user, ...assistants]
|
const messages = [user, ...assistants]
|
||||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
const lastID = userID
|
const lastID = userID
|
||||||
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
const lastAssistant = assistants.at(-1)!
|
||||||
|
const lastPart = lastAssistant.parts.at(-1)!
|
||||||
|
const lastPartID =
|
||||||
|
lastPart.type === "tool"
|
||||||
|
? lastPart.id
|
||||||
|
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||||
|
|
||||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(180_000)
|
benchmark.setTimeout(180_000)
|
||||||
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||||
)
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
data: {
|
||||||
|
...target,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
location: { directory: target.directory },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: target,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
parent: requests.filter((request) => request.type === "parent").length,
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
}
|
}
|
||||||
if (mode === "candidate") {
|
if (mode === "candidate") {
|
||||||
expect(requestCounts.parent).toBe(1)
|
expect(requestCounts.parent).toBe(0)
|
||||||
expect(historyGates).toBe(1)
|
expect(historyGates).toBe(0)
|
||||||
}
|
}
|
||||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
|||||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
@@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route)
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
@@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: current.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/OpenFileExpand"
|
||||||
|
const projectID = "proj_open_file_expand"
|
||||||
|
const sessionID = "ses_open_file_expand"
|
||||||
|
const title = "Open file expand"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-expand",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path === "frontend\\" || path === "frontend") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "app.ts",
|
||||||
|
path: "frontend\\app.ts",
|
||||||
|
absolute: `${directory}/frontend/app.ts`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "frontend",
|
||||||
|
path: "frontend\\",
|
||||||
|
absolute: `${directory}/frontend`,
|
||||||
|
type: "directory" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
path: "README.md",
|
||||||
|
absolute: `${directory}/README.md`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
|
||||||
|
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||||
|
await expect(frontendRow).toBeVisible()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await frontendRow.click()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
|
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||||
|
await expect(appRow).toBeVisible()
|
||||||
|
await appRow.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
||||||
|
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
||||||
|
|
||||||
|
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
||||||
|
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
||||||
|
const OUTSIDE_CAP = "foxtrot-docs"
|
||||||
|
|
||||||
|
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
||||||
|
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
||||||
|
const rows = (page: Page) => page.locator("[data-directory-path]")
|
||||||
|
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
||||||
|
|
||||||
|
async function openProjectDialog(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
fileList: () => [],
|
||||||
|
findFiles: () => [],
|
||||||
|
})
|
||||||
|
await page.addInitScript((dirs) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
||||||
|
lastProject: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, worktrees)
|
||||||
|
await page.goto("/")
|
||||||
|
const add = page.getByRole("button", { name: "Add project" }).first()
|
||||||
|
await expectAppVisible(add)
|
||||||
|
await add.click()
|
||||||
|
await expect(rows(page)).toHaveCount(5)
|
||||||
|
return page.getByRole("textbox").last()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
||||||
|
const search = await openProjectDialog(page)
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||||
|
|
||||||
|
await search.fill("foxtrot")
|
||||||
|
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("still caps the idle recent list at five projects", async ({ page }) => {
|
||||||
|
await openProjectDialog(page)
|
||||||
|
|
||||||
|
await expect(row(page, NAMES[4])).toHaveCount(1)
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||||
|
const projectID = "proj_prompt_input_v2_editing"
|
||||||
|
const sessionID = "ses_prompt_input_v2_editing"
|
||||||
|
|
||||||
|
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "prompt-input-v2-editing",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "prompt-input-v2-editing",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Prompt input V2 editing",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
|
await input.fill("keep me")
|
||||||
|
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||||
|
await page.locator('[data-suggestion-id="model.choose"]').click()
|
||||||
|
|
||||||
|
await expect(input).toHaveText("keep me")
|
||||||
|
})
|
||||||
@@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
const composer = page.locator('[data-component="session-composer"]')
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
const control = composer.locator('[data-component="prompt-variant-control"]')
|
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||||
await expectAppVisible(composer)
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
await idleComposer(page)
|
await idleComposer(page)
|
||||||
await expect(control).toBeHidden()
|
|
||||||
|
|
||||||
await composer.hover()
|
|
||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
await control.click()
|
||||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||||
await expect(high).toBeVisible()
|
await expect(high).toBeVisible()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
await page.keyboard.press("Control+,")
|
||||||
|
|
||||||
const dialog = page.locator(".settings-v2-dialog")
|
const dialog = page.locator(".settings-v2-dialog")
|
||||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
|||||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
await page.keyboard.press("Control+,")
|
||||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||||
@@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session/status") return json(route, {})
|
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
return json(route, { data: [] })
|
||||||
|
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||||
|
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
||||||
|
return json(route, { location: { directory }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp/resource")
|
||||||
|
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
|
||||||
|
if (url.pathname === "/api/project") {
|
||||||
|
return json(route, [
|
||||||
|
{
|
||||||
|
id: remote ? sessionB.projectID : "project-server-a",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/project/current")
|
||||||
|
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||||
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||||
|
return json(route, { data: [], cursor: {} })
|
||||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
if (current) return json(route, current)
|
if (current) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
@@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
directory,
|
directory,
|
||||||
home: directory,
|
home: directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||||
|
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -57,11 +58,15 @@ async function mockServers(page: Page) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route, url.pathname === "/api/event")
|
||||||
if (url.pathname === "/session/status")
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/api/session/active")
|
||||||
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
@@ -90,7 +95,20 @@ async function mockServers(page: Page) {
|
|||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: current.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route) {
|
function sse(route: Route, current: boolean) {
|
||||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
|||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 700, height: 900 })
|
await page.setViewportSize({ width: 700, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_review_line_comment_regression",
|
id: "proj_review_line_comment_regression",
|
||||||
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
|
|||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
|
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
||||||
await page.getByRole("tab", { name: "Changes" }).click()
|
await page.getByRole("tab", { name: "Changes" }).click()
|
||||||
expect(await (await diffResponse).json()).toHaveLength(1)
|
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
await panel.locator("#session-side-panel-review-tab").click()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
await page.keyboard.press("Control+w")
|
await page.keyboard.press("Control+w")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
|||||||
|
|
||||||
async function selectMode(page: Page, current: string, next: string) {
|
async function selectMode(page: Page, current: string, next: string) {
|
||||||
await page.getByRole("button", { name: current }).click()
|
await page.getByRole("button", { name: current }).click()
|
||||||
await page.getByRole("option", { name: next }).click()
|
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFile(page: Page, file: string) {
|
async function selectFile(page: Page, file: string) {
|
||||||
@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const branchDiffs = [
|
|||||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||||
|
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
||||||
let detailVersion = 1
|
let detailVersion = 1
|
||||||
let detailFailures = 1
|
let detailFailures = 1
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessionStatus: { [sessionID]: { type: "idle" } },
|
sessionStatus: () => sessionStatus,
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
@@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
body: JSON.stringify({
|
||||||
|
branch: "review-pane-performance",
|
||||||
|
default_branch: "dev",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/vcs/diff**", (route) => {
|
await page.route("**/vcs/diff**", (route) => {
|
||||||
@@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/pty/pty_review_terminal", (route) =>
|
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||||
await expect(preview).toContainText("after-1")
|
await expect(preview).toContainText("after-1")
|
||||||
detailVersion = 2
|
detailVersion = 2
|
||||||
|
sessionStatus[sessionID] = { type: "busy" }
|
||||||
events.push(statusEvent("busy"))
|
events.push(statusEvent("busy"))
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
const refreshedDiff = page.waitForRequest((request) => {
|
const refreshedDiff = page.waitForRequest((request) => {
|
||||||
@@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
sessionStatus[sessionID] = { type: "idle" }
|
||||||
events.push(statusEvent("idle"))
|
events.push(statusEvent("idle"))
|
||||||
await refreshedDiff
|
await refreshedDiff
|
||||||
await expect(preview).toContainText("after-2")
|
await expect(preview).toContainText("after-2")
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
|||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/path?*", async (route) => {
|
await page.route("**/api/path?*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||||
|
rejectRequests.push(request.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
|
|
||||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
(request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
const request = await reply
|
const request = await reply
|
||||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
@@ -170,6 +173,7 @@ async function mockServer(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ for (const expanded of [false, true]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
||||||
|
const id = "prt_shell_running_command"
|
||||||
|
const command = "sleep 10 && echo done"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
||||||
|
settings: { shellToolPartsExpanded: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||||
|
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||||
|
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
||||||
|
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
||||||
|
await tool.locator('[data-slot="collapsible-trigger"]').click()
|
||||||
|
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
||||||
|
})
|
||||||
|
|
||||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||||
const reasoningID = "prt_reasoning_hidden"
|
const reasoningID = "prt_reasoning_hidden"
|
||||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||||
id: "timeline-event-7",
|
id: "timeline-event-7",
|
||||||
})
|
})
|
||||||
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
|
|||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes through non-event fetches", async ({ page }) => {
|
test("passes through non-event fetches", async ({ page }) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
|||||||
test.setTimeout(90_000)
|
test.setTimeout(90_000)
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
|
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||||
|
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
@@ -56,9 +57,11 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
|||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
},
|
},
|
||||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
|
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
|
sessionStatus: () => sessionStatus,
|
||||||
todos: (sessionID) => todos[sessionID] ?? [],
|
todos: (sessionID) => todos[sessionID] ?? [],
|
||||||
})
|
})
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
@@ -68,6 +71,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
|||||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||||
await expect(dock).toHaveCount(0)
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
sessionStatus[sourceID] = { type: "busy" }
|
||||||
events.push(statusEvent(sourceID, "busy"))
|
events.push(statusEvent(sourceID, "busy"))
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
|||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
eventRetry: events ? 16 : undefined,
|
||||||
})
|
})
|
||||||
// The child session resolves via /session/:id but is absent from the /session list,
|
// The child session resolves by ID but is absent from the session list,
|
||||||
// matching a subagent session that has not been loaded into the list cache yet.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
await page.route(
|
||||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||||
(route) =>
|
(route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
body: JSON.stringify({
|
||||||
|
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||||
|
cursor: {},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
|
const sessionC = session("ses_tab_c", "Tab C session")
|
||||||
|
const unresolvedSessionID = "ses_tab_unresolved"
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
@@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
|||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||||
|
await mockServer(page)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ server, sessionA, unresolved, sessionC }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
{ type: "session", server, sessionId: sessionA },
|
||||||
|
{ type: "session", server, sessionId: unresolved },
|
||||||
|
{ type: "session", server, sessionId: sessionC },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||||
|
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
||||||
|
await page.goto(hrefA)
|
||||||
|
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
||||||
|
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Alt+ArrowRight")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
})
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -52,13 +83,21 @@ function session(id: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB]
|
const sessions = [sessionA, sessionB, sessionC]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
|
return new Promise(() => {})
|
||||||
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||||
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||||
|
return json(route, { data: [], cursor: {} })
|
||||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||||
if (byId) return json(route, byId)
|
if (byId) return json(route, byId)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
@@ -66,8 +105,7 @@ async function mockServer(page: Page) {
|
|||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
@@ -89,7 +127,20 @@ async function mockServer(page: Page) {
|
|||||||
directory: sessionA.directory,
|
directory: sessionA.directory,
|
||||||
home: sessionA.directory,
|
home: sessionA.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: sessionA.directory,
|
||||||
|
config: sessionA.directory,
|
||||||
|
worktree: sessionA.directory,
|
||||||
|
directory: sessionA.directory,
|
||||||
|
home: sessionA.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: sessionA.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) => {
|
||||||
|
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
})
|
})
|
||||||
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
|||||||
const ghostty = Promise.withResolvers<void>()
|
const ghostty = Promise.withResolvers<void>()
|
||||||
const release = Promise.withResolvers<void>()
|
const release = Promise.withResolvers<void>()
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty*", (route) => {
|
||||||
created.count += 1
|
created.count += 1
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route(/ghostty-web/, async (route) => {
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
@@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
|||||||
|
|
||||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty*", (route) => {
|
||||||
created.count += 1
|
created.count += 1
|
||||||
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(next),
|
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
|
|||||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo(id: string, title: string) {
|
||||||
|
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
|
|||||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_hidden_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_hidden_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
|||||||
const terminal = page.locator('[data-component="terminal"]')
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
await expect(terminal).toBeVisible()
|
await expect(terminal).toBeVisible()
|
||||||
await expect.poll(() => connections.length).toBe(1)
|
await expect.poll(() => connections.length).toBe(1)
|
||||||
|
const connection = new URL(connections[0]!)
|
||||||
|
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||||
|
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -85,26 +90,33 @@ async function setup(page: Page) {
|
|||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
|
||||||
|
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
const connections: string[] = []
|
const connections: string[] = []
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||||
connections.push(ws.url())
|
connections.push(ws.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
|
|||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo() {
|
||||||
|
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/NewProject"
|
||||||
|
|
||||||
|
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||||
|
let connectedGo = false
|
||||||
|
let pendingGo = false
|
||||||
|
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: "proj_model_selection_flow",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "NewProject",
|
||||||
|
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: () => ({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"free-model": {
|
||||||
|
id: "free-model",
|
||||||
|
name: "Free Model",
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode-go",
|
||||||
|
name: "OpenCode Go",
|
||||||
|
models: {
|
||||||
|
"go-model-1": {
|
||||||
|
id: "go-model-1",
|
||||||
|
name: "Go Model 1",
|
||||||
|
cost: { input: 1, output: 1 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "free-model" },
|
||||||
|
}),
|
||||||
|
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||||
|
onConnectKey: (input) => {
|
||||||
|
connections.push(input)
|
||||||
|
if (input.integrationID === "opencode-go") pendingGo = true
|
||||||
|
},
|
||||||
|
onInstanceDispose: () => {
|
||||||
|
if (pendingGo) connectedGo = true
|
||||||
|
},
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
fileList: (path) =>
|
||||||
|
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||||
|
findFiles: () => ["NewProject"],
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/")
|
||||||
|
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||||
|
await expectAppVisible(addProject)
|
||||||
|
await addProject.click()
|
||||||
|
await page.locator("[data-directory-path]").click()
|
||||||
|
|
||||||
|
await page.locator('[data-action="home-new-session"]').click()
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||||
|
|
||||||
|
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||||
|
await modelControl.click()
|
||||||
|
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||||
|
|
||||||
|
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||||
|
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||||
|
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||||
|
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||||
|
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||||
|
|
||||||
|
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||||
|
await modelControl.click()
|
||||||
|
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||||
|
await expect(goModel).toBeVisible()
|
||||||
|
await goModel.click()
|
||||||
|
|
||||||
|
await expect(modelControl).toContainText("Go Model 1")
|
||||||
|
})
|
||||||
@@ -4,7 +4,11 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
|
|||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
provider: unknown
|
protocol?: "v1" | "v2"
|
||||||
|
provider: unknown | (() => unknown)
|
||||||
|
integrationMethods?: Record<string, unknown[]>
|
||||||
|
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||||
|
onInstanceDispose?: () => void
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
@@ -23,14 +27,13 @@ export interface MockServerConfig {
|
|||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: unknown
|
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/provider": config.provider,
|
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
config: config.directory,
|
config: config.directory,
|
||||||
@@ -54,19 +57,47 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
const events = config.events?.()
|
||||||
if (path === "/api/session")
|
return sse(
|
||||||
return json(route, {
|
route,
|
||||||
data: config.sessions.map((session) => v2Session(session, config.directory)),
|
path === "/api/event"
|
||||||
cursor: {},
|
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||||
})
|
: [
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
...(path === "/global/event"
|
||||||
|
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||||
|
: []),
|
||||||
|
...(events ?? []),
|
||||||
|
],
|
||||||
|
config.eventRetry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (path === "/global/health")
|
||||||
|
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||||
|
if (path === "/api/health" && config.protocol === "v2")
|
||||||
|
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
|
if (path === "/provider")
|
||||||
|
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||||
|
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||||
|
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||||
|
if (legacyAuth && route.request().method() === "PUT") {
|
||||||
|
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||||
|
config.onInstanceDispose?.()
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
if (path === "/session/status")
|
||||||
|
return json(
|
||||||
|
route,
|
||||||
|
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||||
|
)
|
||||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (path === "/file" && config.fileList)
|
if (path === "/file" && config.fileList)
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
@@ -89,10 +120,138 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
},
|
},
|
||||||
data: [],
|
data: [],
|
||||||
})
|
})
|
||||||
|
if (path === "/api/agent")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "build",
|
||||||
|
name: "Build",
|
||||||
|
mode: "primary",
|
||||||
|
hidden: false,
|
||||||
|
request: { settings: {}, headers: {}, body: {} },
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/mcp/resource")
|
||||||
|
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||||
|
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
||||||
|
if (integration && route.request().method() === "GET")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||||
|
})
|
||||||
|
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||||
|
if (integrationConnect && route.request().method() === "POST") {
|
||||||
|
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (path === "/api/project") return json(route, [config.project])
|
||||||
|
if (path === "/api/project/current")
|
||||||
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
|
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
||||||
|
if (path === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: config.directory,
|
||||||
|
config: config.directory,
|
||||||
|
worktree: config.directory,
|
||||||
|
directory: config.directory,
|
||||||
|
home: "C:/OpenCode",
|
||||||
|
})
|
||||||
|
if (path === "/api/permission/request")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||||
|
currentPermission,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if (path === "/api/question/request")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||||
|
})
|
||||||
|
if (path === "/api/vcs")
|
||||||
|
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||||
|
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||||
|
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||||
|
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||||
|
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
|
if (path === "/api/session") {
|
||||||
|
const directory = url.searchParams.get("directory")
|
||||||
|
const parentID = url.searchParams.get("parentID")
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
|
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||||
|
const sessions = config.sessions
|
||||||
|
.filter((session) => !directory || session.directory === directory)
|
||||||
|
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||||
|
.filter((session) => {
|
||||||
|
const search = url.searchParams.get("search")?.toLowerCase()
|
||||||
|
return (
|
||||||
|
!search ||
|
||||||
|
String(session.title ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||||
|
const data = ordered.slice(offset, offset + limit)
|
||||||
|
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||||
|
return json(route, {
|
||||||
|
data: data.map((session) => currentSession(session, config.directory)),
|
||||||
|
cursor: { next },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === "/api/session/active") {
|
||||||
|
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||||
|
return json(route, {
|
||||||
|
data: Object.fromEntries(
|
||||||
|
Object.entries(statuses).flatMap(([id, status]) =>
|
||||||
|
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||||
|
route.request().method() === "POST"
|
||||||
|
) {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
|
|
||||||
|
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||||
|
if (currentSessionMatch) {
|
||||||
|
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||||
|
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
||||||
|
return json(route, {
|
||||||
|
data: currentSession(session, config.directory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||||
if (sessionMatch) {
|
if (sessionMatch) {
|
||||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||||
@@ -115,6 +274,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
|
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||||
|
if (currentMessagesMatch) {
|
||||||
|
const token = url.searchParams.get("cursor") ?? undefined
|
||||||
|
const before = token ? cursors.get(token) : undefined
|
||||||
|
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||||
|
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
||||||
|
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
||||||
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
|
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||||
|
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
||||||
|
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||||
|
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||||
|
return json(route, {
|
||||||
|
data: pageData.items.map(currentMessage).reverse(),
|
||||||
|
cursor: { next: cursor },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const token = url.searchParams.get("before") ?? undefined
|
const token = url.searchParams.get("before") ?? undefined
|
||||||
@@ -137,12 +314,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
|
function location(config: MockServerConfig) {
|
||||||
|
return {
|
||||||
|
directory: config.directory,
|
||||||
|
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPermission(value: unknown) {
|
||||||
|
const permission = value as Record<string, unknown>
|
||||||
|
if (permission.action) return permission
|
||||||
|
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
||||||
|
return {
|
||||||
|
id: permission.id,
|
||||||
|
sessionID: permission.sessionID,
|
||||||
|
action: permission.permission,
|
||||||
|
resources: permission.patterns ?? [],
|
||||||
|
save: permission.always,
|
||||||
|
metadata: permission.metadata,
|
||||||
|
source:
|
||||||
|
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
projectID: session.projectID ?? "project",
|
projectID: session.projectID ?? "project",
|
||||||
|
agent: session.agent ?? "build",
|
||||||
|
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||||
cost: session.cost ?? 0,
|
cost: session.cost ?? 0,
|
||||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: {
|
time: {
|
||||||
@@ -157,7 +359,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
|
|||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||||
},
|
},
|
||||||
...(typeof session.path === "string" ? { subpath: session.path } : {}),
|
subpath: session.path,
|
||||||
|
revert: session.revert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMessage(value: unknown) {
|
||||||
|
const item = value as {
|
||||||
|
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
||||||
|
parts: Array<Record<string, unknown> & { type: string }>
|
||||||
|
}
|
||||||
|
if (item.info.role === "user") {
|
||||||
|
return {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "user",
|
||||||
|
time: item.info.time,
|
||||||
|
text: item.parts
|
||||||
|
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||||
|
.join("\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "assistant",
|
||||||
|
time: item.info.time,
|
||||||
|
agent: item.info.agent ?? "build",
|
||||||
|
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
||||||
|
cost: item.info.cost,
|
||||||
|
tokens: item.info.tokens,
|
||||||
|
error: item.info.error,
|
||||||
|
content: item.parts.flatMap<unknown>((part) => {
|
||||||
|
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
||||||
|
if (part.type !== "tool") return []
|
||||||
|
const state = part.state as Record<string, unknown>
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: part.id,
|
||||||
|
name: part.tool,
|
||||||
|
time: state.time ?? { created: item.info.time.created },
|
||||||
|
state:
|
||||||
|
state.status === "pending"
|
||||||
|
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
||||||
|
: state.status === "completed"
|
||||||
|
? {
|
||||||
|
status: "completed",
|
||||||
|
input: state.input ?? {},
|
||||||
|
structured: state.metadata ?? {},
|
||||||
|
content: [{ type: "text", text: state.output ?? "" }],
|
||||||
|
}
|
||||||
|
: state.status === "error"
|
||||||
|
? {
|
||||||
|
status: "error",
|
||||||
|
input: state.input ?? {},
|
||||||
|
structured: state.metadata ?? {},
|
||||||
|
content: [],
|
||||||
|
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
||||||
|
}
|
||||||
|
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,3 +443,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
|||||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentEvent(input: unknown) {
|
||||||
|
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||||
|
const envelope = input as { directory?: string; payload?: unknown }
|
||||||
|
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||||
|
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||||
|
if (!payload.type) return input
|
||||||
|
return {
|
||||||
|
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||||
|
created: Date.now(),
|
||||||
|
type: payload.type,
|
||||||
|
data: payload.properties ?? {},
|
||||||
|
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
|||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event"
|
path: "/global/event" | "/event" | "/api/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
@@ -93,6 +93,21 @@ export async function installSseTransport<T>(
|
|||||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
].join("")
|
].join("")
|
||||||
|
const currentEvent = (input: unknown) => {
|
||||||
|
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||||
|
const envelope = input as { directory?: string; payload?: unknown }
|
||||||
|
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||||
|
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||||
|
if (!payload.type) return input
|
||||||
|
return {
|
||||||
|
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||||
|
created: Date.now(),
|
||||||
|
type: payload.type,
|
||||||
|
data: payload.properties ?? {},
|
||||||
|
location:
|
||||||
|
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
const acknowledge = (
|
const acknowledge = (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
@@ -140,15 +155,13 @@ export async function installSseTransport<T>(
|
|||||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||||
return acknowledge(connection, input.bytes.length, output.length)
|
return acknowledge(connection, input.bytes.length, output.length)
|
||||||
}
|
}
|
||||||
const encoded = input.deliveries.map((delivery) => ({
|
const encoded = input.deliveries.map((delivery) => {
|
||||||
delivery,
|
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||||
}))
|
})
|
||||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||||
if (input.burst) {
|
if (input.burst) {
|
||||||
const bytes = encoder.encode(
|
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
|
||||||
)
|
|
||||||
connection.controller.enqueue(bytes)
|
connection.controller.enqueue(bytes)
|
||||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||||
}
|
}
|
||||||
@@ -161,7 +174,10 @@ export async function installSseTransport<T>(
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
if (
|
||||||
|
url.origin !== server ||
|
||||||
|
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||||
|
)
|
||||||
return originalFetch(request)
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
@@ -177,6 +193,18 @@ export async function installSseTransport<T>(
|
|||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||||
|
if (url.pathname === "/api/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
|
)
|
||||||
|
if (url.pathname === "/global/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
frame({
|
||||||
|
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.1",
|
"version": "1.18.12",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit && bun run test:browser",
|
"test": "bun run test:unit && bun run test:browser",
|
||||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SerializeAddon", () => {
|
describe("SerializeAddon", () => {
|
||||||
|
test("preserves color scheme reporting mode", async () => {
|
||||||
|
const { term, addon } = createTerminal()
|
||||||
|
await writeAndWait(term, "\x1b[?2031h")
|
||||||
|
|
||||||
|
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
|
||||||
|
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
describe("ANSI color preservation", () => {
|
describe("ANSI color preservation", () => {
|
||||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||||
const { term, addon } = createTerminal()
|
const { term, addon } = createTerminal()
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
|
|||||||
return { active, normal, alternate }
|
return { active, normal, alternate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTerminalMode = (value: ITerminalCore, mode: number) => {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
const terminal = value.wasmTerm
|
||||||
|
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
|
||||||
|
return terminal.getMode(mode) === true
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = options?.range
|
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
|
||||||
|
content += options?.range
|
||||||
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
||||||
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
|
|||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import {
|
||||||
|
type BaseRouterProps,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Router,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
@@ -29,6 +38,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
@@ -57,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
|||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome } from "@/pages/home"
|
||||||
|
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
@@ -226,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (settings.general.newLayoutDesigns()) return
|
||||||
|
const current = server.current
|
||||||
|
if (!current) return
|
||||||
|
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||||
|
if (protocol !== "v2") return
|
||||||
|
const next = global.servers.list().find((s) => {
|
||||||
|
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||||
|
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||||
|
})
|
||||||
|
if (!next) return
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
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 { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -24,6 +27,8 @@ export type CommandPaletteEntry = {
|
|||||||
path?: string
|
path?: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
|
server?: ServerConnection.Key
|
||||||
|
project?: LocalProject
|
||||||
archived?: number
|
archived?: number
|
||||||
updated?: number
|
updated?: number
|
||||||
}
|
}
|
||||||
@@ -75,28 +80,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
|||||||
|
|
||||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const layout = useLayout()
|
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const navigate = useNavigate()
|
|
||||||
const serverSDK = useServerSDK()()
|
const serverSDK = useServerSDK()()
|
||||||
const serverSync = useServerSync()
|
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
||||||
const { params, tabs } = useSessionLayout()
|
const appTabs = useTabs()
|
||||||
|
const { tabs: sessionTabs } = useSessionLayout()
|
||||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const filesOnly = () => props.filesOnly?.() ?? false
|
const filesOnly = () => props.filesOnly?.() ?? false
|
||||||
|
|
||||||
const allowedCommands = createMemo(() => {
|
const allowedCommands = createMemo(() => {
|
||||||
if (filesOnly()) return []
|
if (filesOnly()) return []
|
||||||
return command.options.filter(
|
return commandPaletteOptions(command.options)
|
||||||
(option) =>
|
|
||||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
const commandEntries = createMemo(() => {
|
const commandEntries = createMemo(() => {
|
||||||
const category = language.t("palette.group.commands")
|
const category = language.t("palette.group.commands")
|
||||||
return allowedCommands().map((option) => createCommandEntry(option, category))
|
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
|
||||||
})
|
})
|
||||||
const preferredCommandEntries = createMemo(() => {
|
const preferredCommandEntries = createMemo(() => {
|
||||||
const all = allowedCommands()
|
const all = allowedCommands()
|
||||||
@@ -105,11 +107,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
const category = language.t("palette.group.commands")
|
const category = language.t("palette.group.commands")
|
||||||
return sorted.map((option) => createCommandEntry(option, category))
|
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs,
|
tabs: sessionTabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
@@ -140,36 +142,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
.map((path) => createCommandPaletteFileEntry(path, category))
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const sessions = createServerSessionEntries({
|
||||||
const project = createMemo(() => {
|
server: ServerConnection.key(serverSDK.server),
|
||||||
const directory = projectDirectory()
|
opened: serverCtx.projects.list,
|
||||||
if (!directory) return undefined
|
stored: () => serverCtx.sync.data.project,
|
||||||
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
})
|
|
||||||
const workspaces = createMemo(() => {
|
|
||||||
const directory = projectDirectory()
|
|
||||||
const current = project()
|
|
||||||
if (!current) return directory ? [directory] : []
|
|
||||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
|
||||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
|
||||||
return dirs
|
|
||||||
})
|
|
||||||
const homedir = createMemo(() => serverSync().data.path.home)
|
|
||||||
const sessions = createSessionEntries({
|
|
||||||
workspaces,
|
|
||||||
label: (directory) => {
|
|
||||||
const current = project()
|
|
||||||
const kind =
|
|
||||||
current && directory === current.worktree
|
|
||||||
? language.t("workspace.type.local")
|
|
||||||
: language.t("workspace.type.sandbox")
|
|
||||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const home = homedir()
|
|
||||||
const path = home ? directory.replace(home, "~") : directory
|
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
|
||||||
return `${kind} : ${name || path}`
|
|
||||||
},
|
|
||||||
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
@@ -191,8 +168,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (item.type === "session") {
|
if (item.type === "session") {
|
||||||
if (!item.directory || !item.sessionID) return
|
if (!item.sessionID || !item.server) return
|
||||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
const directory = item.project?.worktree ?? item.directory
|
||||||
|
if (directory) {
|
||||||
|
serverCtx.projects.open(directory)
|
||||||
|
serverCtx.projects.touch(directory)
|
||||||
|
}
|
||||||
|
const tab = appTabs.addSessionTab({
|
||||||
|
server: item.server,
|
||||||
|
sessionId: item.sessionID,
|
||||||
|
})
|
||||||
|
appTabs.select(tab)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!item.path) return
|
if (!item.path) return
|
||||||
@@ -218,7 +204,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||||
return {
|
return {
|
||||||
id: "command:" + option.id,
|
id: "command:" + option.id,
|
||||||
type: "command",
|
type: "command",
|
||||||
@@ -230,96 +216,66 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionEntries(props: {
|
export function createServerSessionEntries(props: {
|
||||||
workspaces: () => string[]
|
server: ServerConnection.Key
|
||||||
label: (directory: string) => string
|
opened: () => LocalProject[]
|
||||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
stored: () => Project[]
|
||||||
|
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
const state: {
|
let abort: AbortController | undefined
|
||||||
token: number
|
|
||||||
inflight: Promise<CommandPaletteEntry[]> | undefined
|
|
||||||
cached: CommandPaletteEntry[] | undefined
|
|
||||||
} = { token: 0, inflight: undefined, cached: undefined }
|
|
||||||
|
|
||||||
return (text: string) => {
|
onCleanup(() => abort?.abort())
|
||||||
if (!text.trim()) {
|
|
||||||
state.token += 1
|
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||||
state.inflight = undefined
|
const search = text.trim()
|
||||||
state.cached = undefined
|
if (!search) {
|
||||||
return [] as CommandPaletteEntry[]
|
abort?.abort()
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
if (state.cached) return state.cached
|
abort?.abort()
|
||||||
if (state.inflight) return state.inflight
|
const current = new AbortController()
|
||||||
|
abort = current
|
||||||
const current = state.token
|
await new Promise<void>((resolve) => {
|
||||||
const dirs = props.workspaces()
|
const timer = setTimeout(resolve, 100)
|
||||||
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
current.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
state.inflight = Promise.all(
|
() => {
|
||||||
dirs.map((directory) => {
|
clearTimeout(timer)
|
||||||
const description = props.label(directory)
|
resolve()
|
||||||
return props
|
},
|
||||||
.load(directory)
|
{ once: true },
|
||||||
.then((result) =>
|
)
|
||||||
(result.data ?? [])
|
})
|
||||||
.filter((session) => !!session?.id)
|
if (current.signal.aborted) return []
|
||||||
.map((session) => ({
|
const opened = props.opened()
|
||||||
id: session.id,
|
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||||
title: session.title ?? props.untitled(),
|
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||||
description,
|
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||||
directory,
|
return props
|
||||||
archived: session.time?.archived,
|
.load(search, current.signal)
|
||||||
updated: session.time?.updated,
|
.then((result) =>
|
||||||
})),
|
result.data
|
||||||
)
|
.map(normalizeSessionInfo)
|
||||||
.catch(() => [] as SessionEntryInput[])
|
.filter((session) => !session.time.archived)
|
||||||
}),
|
.map((session) => {
|
||||||
)
|
const project =
|
||||||
.then((results) => {
|
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||||
if (state.token !== current) return [] as CommandPaletteEntry[]
|
return {
|
||||||
const seen = new Set<string>()
|
id: `session:${props.server}:${session.id}`,
|
||||||
const next = results
|
type: "session" as const,
|
||||||
.flat()
|
title: session.title || props.untitled(),
|
||||||
.filter((item) => {
|
description: project ? displayName(project) : getFilename(session.directory),
|
||||||
const key = `${item.directory}:${item.id}`
|
category: props.category(),
|
||||||
if (seen.has(key)) return false
|
directory: session.directory,
|
||||||
seen.add(key)
|
sessionID: session.id,
|
||||||
return true
|
server: props.server,
|
||||||
})
|
project,
|
||||||
.map((item) => createSessionEntry(item, props.category()))
|
updated: session.time.updated,
|
||||||
state.cached = next
|
}
|
||||||
return next
|
}),
|
||||||
})
|
)
|
||||||
.catch(() => [] as CommandPaletteEntry[])
|
.catch(() => [] as CommandPaletteEntry[])
|
||||||
.finally(() => {
|
|
||||||
state.inflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return state.inflight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionEntryInput = {
|
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
|
|
||||||
return {
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category,
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
type Mem = Performance & {
|
type Mem = Performance & {
|
||||||
memory?: {
|
memory?: {
|
||||||
@@ -65,7 +66,7 @@ function Cell(props: {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0 items-center": true,
|
"flex min-w-0 items-center": true,
|
||||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
"justify-center text-center": !props.inline,
|
"justify-center text-center": !props.inline,
|
||||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||||
"col-span-2": !!props.wide && !props.inline,
|
"col-span-2": !!props.wide && !props.inline,
|
||||||
@@ -73,21 +74,29 @@ function Cell(props: {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
"flex min-w-0": true,
|
||||||
|
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||||
|
"flex-col items-center": !props.inline,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.label}
|
<div
|
||||||
</div>
|
classList={{
|
||||||
<div
|
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||||
classList={{
|
}}
|
||||||
"uppercase leading-none font-bold tabular-nums": true,
|
>
|
||||||
"text-[11px]": !!props.inline,
|
{props.label}
|
||||||
"text-[13px] sm:text-[14px]": !props.inline,
|
</div>
|
||||||
"text-text-on-critical-base": !!props.bad,
|
<div
|
||||||
"opacity-70": !!props.dim,
|
classList={{
|
||||||
}}
|
"uppercase leading-none font-bold tabular-nums": true,
|
||||||
>
|
"text-[11px]": !!props.inline,
|
||||||
{props.value}
|
"text-[13px] sm:text-[14px]": !props.inline,
|
||||||
|
"text-text-on-critical-base": !!props.bad,
|
||||||
|
"opacity-70": !!props.dim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -107,8 +116,55 @@ function Cell(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
||||||
|
const content = () => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Force focus styles on all interactive elements"
|
||||||
|
aria-pressed={props.active}
|
||||||
|
classList={{
|
||||||
|
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||||
|
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
|
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||||
|
"bg-surface-raised-base text-text-strong": props.active,
|
||||||
|
}}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
flex: true,
|
||||||
|
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||||
|
"flex-col items-center": !props.inline,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||||
|
<span
|
||||||
|
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
|
||||||
|
>
|
||||||
|
{props.active ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (props.inline) {
|
||||||
|
return (
|
||||||
|
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</TooltipV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routing = useIsRouting()
|
const routing = useIsRouting()
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -116,6 +172,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
delay: undefined as number | undefined,
|
delay: undefined as number | undefined,
|
||||||
fps: undefined as number | undefined,
|
fps: undefined as number | undefined,
|
||||||
gap: undefined as number | undefined,
|
gap: undefined as number | undefined,
|
||||||
|
focus: false,
|
||||||
heap: {
|
heap: {
|
||||||
limit: undefined as number | undefined,
|
limit: undefined as number | undefined,
|
||||||
used: undefined as number | undefined,
|
used: undefined as number | undefined,
|
||||||
@@ -142,6 +199,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||||
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
|
const toggleFocus = async () => {
|
||||||
|
if (!platform.setForceFocus) return
|
||||||
|
const enabled = !state.focus
|
||||||
|
await platform.setForceFocus(enabled)
|
||||||
|
setState("focus", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let start = 0
|
let start = 0
|
||||||
@@ -490,8 +557,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
wide
|
wide={!platform.setForceFocus}
|
||||||
/>
|
/>
|
||||||
|
{platform.setForceFocus && (
|
||||||
|
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { formatKeybindParts } from "@/context/command"
|
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
|
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
import {
|
import {
|
||||||
|
createCommandPaletteCommandEntry,
|
||||||
createCommandPaletteFileEntry,
|
createCommandPaletteFileEntry,
|
||||||
createCommandPaletteModel,
|
createCommandPaletteModel,
|
||||||
|
createServerSessionEntries,
|
||||||
uniqueCommandPaletteEntries,
|
uniqueCommandPaletteEntries,
|
||||||
type CommandPaletteEntry,
|
type CommandPaletteEntry,
|
||||||
} from "./command-palette"
|
} from "./command-palette"
|
||||||
@@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
|||||||
|
|
||||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
const palette = createCommandPaletteModel(props)
|
const palette = createCommandPaletteModel(props)
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const [active, setActive] = createSignal(0)
|
|
||||||
|
|
||||||
const loadItems = async (text: string) => {
|
const loadItems = async (text: string) => {
|
||||||
const q = text.trim()
|
const q = text.trim()
|
||||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
@@ -41,16 +45,104 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
const category = palette.language.t("palette.group.files")
|
const category = palette.language.t("palette.group.files")
|
||||||
return [
|
return [
|
||||||
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||||
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
...nextSessions,
|
||||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
return (
|
||||||
|
<CommandPaletteView
|
||||||
|
placeholder={palette.language.t("palette.search.placeholder")}
|
||||||
|
loadItems={loadItems}
|
||||||
|
highlight={palette.highlight}
|
||||||
|
select={palette.select}
|
||||||
|
close={palette.close}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHomeCommandPaletteV2(props: {
|
||||||
|
server: ServerConnection.Any
|
||||||
|
onSelectSession: (entry: CommandPaletteEntry) => void
|
||||||
|
}) {
|
||||||
|
const command = useCommand()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const global = useGlobal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverCtx = global.ensureServerCtx(props.server)
|
||||||
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
|
const commandEntries = createMemo(() => {
|
||||||
|
const category = language.t("palette.group.commands")
|
||||||
|
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
const sessions = createServerSessionEntries({
|
||||||
|
server: ServerConnection.key(props.server),
|
||||||
|
opened: serverCtx.projects.list,
|
||||||
|
stored: () => serverCtx.sync.data.project,
|
||||||
|
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
|
untitled: () => language.t("command.session.new"),
|
||||||
|
category: () => language.t("command.category.session"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
state.cleanup?.()
|
||||||
|
state.cleanup = undefined
|
||||||
|
if (item?.type !== "command") return
|
||||||
|
state.cleanup = item.option?.onHighlight?.()
|
||||||
|
}
|
||||||
|
const select = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
if (!item) return
|
||||||
|
state.committed = true
|
||||||
|
state.cleanup = undefined
|
||||||
|
dialog.close()
|
||||||
|
if (item.type === "command") {
|
||||||
|
item.option?.onSelect?.("palette")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.type === "session") props.onSelectSession(item)
|
||||||
|
}
|
||||||
|
const loadItems = async (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
if (!query) return commandEntries().slice(0, 5)
|
||||||
|
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.committed) return
|
||||||
|
state.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteView
|
||||||
|
placeholder={language.t("palette.search.placeholder.home")}
|
||||||
|
loadItems={loadItems}
|
||||||
|
highlight={highlight}
|
||||||
|
select={select}
|
||||||
|
close={() => dialog.close()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPaletteView(props: {
|
||||||
|
placeholder: string
|
||||||
|
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
||||||
|
highlight: (item: CommandPaletteEntry | undefined) => void
|
||||||
|
select: (item: CommandPaletteEntry | undefined) => void
|
||||||
|
close: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [active, setActive] = createSignal(0)
|
||||||
|
|
||||||
|
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||||
|
const openSessions = createMemo(
|
||||||
|
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
query()
|
query()
|
||||||
@@ -59,7 +151,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
palette.highlight(activeEntry())
|
props.highlight(activeEntry())
|
||||||
})
|
})
|
||||||
|
|
||||||
let resultsRef: HTMLDivElement | undefined
|
let resultsRef: HTMLDivElement | undefined
|
||||||
@@ -86,12 +178,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
}
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
palette.select(activeEntry())
|
props.select(activeEntry())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
palette.close()
|
props.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +197,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
appearance="large"
|
appearance="large"
|
||||||
placeholder={palette.language.t("palette.search.placeholder")}
|
placeholder={props.placeholder}
|
||||||
leadingIcon={<Icon name="magnifying-glass" />}
|
leadingIcon={<Icon name="magnifying-glass" />}
|
||||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -117,7 +209,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
when={visibleEntries().length > 0}
|
when={visibleEntries().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="command-palette-v2-state">
|
<div class="command-palette-v2-state">
|
||||||
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
|
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -132,9 +224,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
<PaletteRow
|
<PaletteRow
|
||||||
item={item}
|
item={item}
|
||||||
active={activeEntry()?.id === item.id}
|
active={activeEntry()?.id === item.id}
|
||||||
language={palette.language}
|
language={language}
|
||||||
|
sessionOpen={
|
||||||
|
item.server && item.sessionID
|
||||||
|
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
||||||
|
: false
|
||||||
|
}
|
||||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||||
onSelect={() => palette.select(item)}
|
onSelect={() => props.select(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -153,13 +250,19 @@ function PaletteRow(props: {
|
|||||||
item: CommandPaletteEntry
|
item: CommandPaletteEntry
|
||||||
active: boolean
|
active: boolean
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
|
sessionOpen: boolean
|
||||||
onActive: () => void
|
onActive: () => void
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const session = () =>
|
||||||
|
props.item.server && props.item.directory && props.item.sessionID
|
||||||
|
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="command-palette-v2-row"
|
class="command-palette-v2-row group"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={props.active}
|
aria-selected={props.active}
|
||||||
data-active={props.active ? "" : undefined}
|
data-active={props.active ? "" : undefined}
|
||||||
@@ -197,7 +300,25 @@ function PaletteRow(props: {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={props.item.type === "session"}>
|
<Match when={props.item.type === "session"}>
|
||||||
<div class="command-palette-v2-row-main">
|
<div class="command-palette-v2-row-main">
|
||||||
<Icon name="status" class="command-palette-v2-row-icon" />
|
<div class="relative shrink-0">
|
||||||
|
<Show when={props.sessionOpen}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||||
|
style={{ right: "calc(100% + 4px)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={session()}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionTabAvatar
|
||||||
|
project={props.item.project}
|
||||||
|
directory={session().directory}
|
||||||
|
sessionId={session().sessionID}
|
||||||
|
server={session().server}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<div class="command-palette-v2-row-text">
|
<div class="command-palette-v2-row-text">
|
||||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
{props.item.title}
|
{props.item.title}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { mockProviderAuth } from "@/context/server-sync"
|
||||||
|
import { onCleanup, onMount } from "solid-js"
|
||||||
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
|
|
||||||
|
function ConnectProviderDialogStory() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const open = () => dialog.show(() => <DialogConnectProvider />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open connect provider dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderConnectionDialogStory(props) {
|
||||||
|
onCleanup(mockProviderAuth(props.provider, props.methods))
|
||||||
|
const dialog = useDialog()
|
||||||
|
const controller = useProviderConnectController()
|
||||||
|
controller.select(props.provider)
|
||||||
|
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open {props.provider} connection dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnection(provider, methods) {
|
||||||
|
return () => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ProviderConnectionDialogStory provider={provider} methods={methods} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Connect Provider",
|
||||||
|
id: "app-dialog-connect-provider",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const V2 = {
|
||||||
|
render: () => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ConnectProviderDialogStory />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKey = {
|
||||||
|
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenCodeZen = {
|
||||||
|
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginMethods = {
|
||||||
|
render: renderConnection("openai", [
|
||||||
|
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
||||||
|
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
||||||
|
{ type: "api", label: "API key" },
|
||||||
|
]),
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
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 { showToast } from "@/utils/toast"
|
||||||
import {
|
import {
|
||||||
type Accessor,
|
type Accessor,
|
||||||
@@ -16,6 +19,8 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
|
createUniqueId,
|
||||||
|
For,
|
||||||
Match,
|
Match,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
@@ -23,14 +28,18 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
@@ -50,32 +59,22 @@ export const DialogConnectProvider: Component<{
|
|||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
const controller = props.controller ?? fallback
|
const controller = props.controller ?? fallback
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const reset = controller.back
|
const reset = controller.back
|
||||||
const back = { current: reset }
|
const back = { current: reset }
|
||||||
|
let focusHost: HTMLDivElement | undefined
|
||||||
|
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
||||||
const select = (provider?: string) => {
|
const select = (provider?: string) => {
|
||||||
back.current = reset
|
back.current = reset
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function Content() {
|
||||||
<Dialog
|
return (
|
||||||
class="h-full"
|
|
||||||
transition
|
|
||||||
title={
|
|
||||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => back.current()}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={controller.selected() === CUSTOM_ID}>
|
<Match when={controller.selected() === CUSTOM_ID}>
|
||||||
<CustomProviderForm />
|
<CustomProviderForm autofocus={!newLayout()} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
@@ -88,15 +87,77 @@ export const DialogConnectProvider: Component<{
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
<ProviderPicker
|
||||||
|
directory={props.directory}
|
||||||
|
onSelect={select}
|
||||||
|
onPrepare={newLayout() ? holdFocus : undefined}
|
||||||
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Dialog>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={newLayout()}
|
||||||
|
fallback={
|
||||||
|
<Dialog
|
||||||
|
class="h-full"
|
||||||
|
transition
|
||||||
|
title={
|
||||||
|
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogV2
|
||||||
|
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
|
>
|
||||||
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
|
<Show
|
||||||
|
when={controller.selected()}
|
||||||
|
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="small" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
||||||
|
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogV2>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
function ProviderPicker(props: {
|
||||||
const providers = useProviders(props.directory)
|
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 providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
@@ -163,6 +224,157 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: "",
|
||||||
|
active: undefined as string | undefined,
|
||||||
|
connecting: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||||
|
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||||
|
const all = createMemo(() => {
|
||||||
|
language.locale()
|
||||||
|
const query = store.filter.trim().toLowerCase()
|
||||||
|
const values = [custom(), ...providers.all().values()]
|
||||||
|
if (!query) return values
|
||||||
|
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
const popular = createMemo(() =>
|
||||||
|
all()
|
||||||
|
.filter((provider) => featured.includes(provider.id))
|
||||||
|
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
|
||||||
|
)
|
||||||
|
const other = createMemo(() =>
|
||||||
|
all()
|
||||||
|
.filter((provider) => !featured.includes(provider.id))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.id === CUSTOM_ID) return -1
|
||||||
|
if (b.id === CUSTOM_ID) return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const rows = createMemo(() => [...popular(), ...other()])
|
||||||
|
let picker: HTMLDivElement | undefined
|
||||||
|
let search: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
onMount(() => search?.focus({ preventScroll: true }))
|
||||||
|
|
||||||
|
const connect = (provider: string) => {
|
||||||
|
props.onPrepare?.()
|
||||||
|
props.onSelect(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (event: KeyboardEvent, direction: number) => {
|
||||||
|
const items = rows()
|
||||||
|
if (items.length === 0) return
|
||||||
|
const index = items.findIndex((provider) => provider.id === store.active)
|
||||||
|
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
||||||
|
setStore("active", items[next].id)
|
||||||
|
picker
|
||||||
|
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
|
||||||
|
?.focus({ preventScroll: true })
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "ArrowDown") return move(event, 1)
|
||||||
|
if (event.key === "ArrowUp") return move(event, -1)
|
||||||
|
if (event.key !== "Enter" || !store.active) return
|
||||||
|
connect(store.active)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
|
||||||
|
<div class="shrink-0 px-1 pt-px">
|
||||||
|
<TextInputV2
|
||||||
|
ref={search}
|
||||||
|
type="search"
|
||||||
|
class="!w-full [font-family:var(--v2-font-family-sans)]"
|
||||||
|
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||||
|
placeholder={language.t("dialog.provider.search.placeholder")}
|
||||||
|
value={store.filter}
|
||||||
|
onInput={(event) => {
|
||||||
|
setStore({ filter: event.currentTarget.value, active: undefined })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative min-h-0 flex-1">
|
||||||
|
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
<For
|
||||||
|
each={[
|
||||||
|
{ title: language.t("dialog.provider.group.popular"), items: popular },
|
||||||
|
{ title: language.t("dialog.provider.group.other"), items: other },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(group) => (
|
||||||
|
<Show when={group.items().length > 0}>
|
||||||
|
<section class="flex flex-col">
|
||||||
|
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<For each={group.items()}>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-provider-id={provider.id}
|
||||||
|
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
||||||
|
onMouseEnter={() => setStore("active", provider.id)}
|
||||||
|
disabled={store.connecting !== undefined}
|
||||||
|
aria-busy={store.connecting === provider.id}
|
||||||
|
onClick={() => connect(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
|
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t(
|
||||||
|
provider.id === "opencode"
|
||||||
|
? "dialog.provider.opencode.tagline"
|
||||||
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
{language.t("dialog.provider.tag.recommended")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={provider.id === CUSTOM_ID}>
|
||||||
|
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
{language.t("settings.providers.tag.custom")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.connecting === provider.id}>
|
||||||
|
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={rows().length === 0}>
|
||||||
|
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("dialog.provider.empty")}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
||||||
|
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderConnection(props: {
|
function ProviderConnection(props: {
|
||||||
provider: string
|
provider: string
|
||||||
directory?: Accessor<string | undefined>
|
directory?: Accessor<string | undefined>
|
||||||
@@ -172,8 +384,16 @@ function ProviderConnection(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders(props.directory)
|
const settings = useSettings()
|
||||||
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
|
const providers = useProviders(() => props.directory?.())
|
||||||
|
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||||
|
const location = () => {
|
||||||
|
const value = directory()
|
||||||
|
return value ? { directory: value } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
@@ -188,28 +408,32 @@ function ProviderConnection(props: {
|
|||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||||
{
|
{
|
||||||
type: "api" as const,
|
type: "key" as const,
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const [auth] = createResource(
|
const [integration] = createResource(
|
||||||
() => props.provider,
|
() => ({ provider: props.provider, directory: directory() }),
|
||||||
async () => {
|
(input) =>
|
||||||
const cached = serverSync().data.provider_auth[props.provider]
|
serverSDK()
|
||||||
if (cached) return cached
|
.api.integration.get({
|
||||||
const res = await serverSDK().client.provider.auth()
|
integrationID: input.provider,
|
||||||
if (!alive.value) return fallback()
|
location: input.directory ? { directory: input.directory } : undefined,
|
||||||
serverSync().set("provider_auth", res.data ?? {})
|
})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
.then((result) => result.data),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => integration.loading)
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo<ConnectMethod[]>(() => {
|
||||||
|
const values = integration.latest?.methods.filter(
|
||||||
|
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||||
|
)
|
||||||
|
return values?.length ? values : fallback()
|
||||||
|
})
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
@@ -221,7 +445,7 @@ function ProviderConnection(props: {
|
|||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
@@ -275,10 +499,20 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const methodDetails = (value?: { type?: string; label?: string }) => {
|
||||||
|
const label = methodLabel(value)
|
||||||
|
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
|
||||||
|
const hint = suffix?.[1]
|
||||||
|
return {
|
||||||
|
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||||
|
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatError(value: unknown, fallback: string): string {
|
function formatError(value: unknown, fallback: string): string {
|
||||||
if (value && typeof value === "object" && "data" in value) {
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
const data = (value as { data?: { message?: unknown } }).data
|
const data = (value as { data?: { message?: unknown } }).data
|
||||||
@@ -306,46 +540,22 @@ function ProviderConnection(props: {
|
|||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
if (method.type === "api" && method.prompts?.length) {
|
|
||||||
if (!inputs) {
|
|
||||||
dispatch({ type: "auth.prompt" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({ type: "auth.inputs", inputs })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.client.provider.oauth.authorize(
|
.api.integration.oauth.connect({
|
||||||
{
|
integrationID: props.provider,
|
||||||
providerID: props.provider,
|
methodID: method.id,
|
||||||
method: index,
|
inputs: inputs ?? {},
|
||||||
inputs,
|
location: location(),
|
||||||
},
|
})
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
const elapsed = Date.now() - start
|
dispatch({ type: "auth.complete", authorization: x.data })
|
||||||
const delay = 1000 - elapsed
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
timer.current = undefined
|
|
||||||
if (!alive.value) return
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
|
||||||
}, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
@@ -360,9 +570,9 @@ function ProviderConnection(props: {
|
|||||||
index: 0,
|
index: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
const prompts = createMemo(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
return value?.prompts ?? []
|
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
@@ -393,10 +603,6 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (method()?.type === "api") {
|
|
||||||
dispatch({ type: "auth.inputs", inputs: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +704,9 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSDK().client.global.dispose()
|
await serverSync()
|
||||||
|
.refreshProviders()
|
||||||
|
.catch(() => undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -519,6 +727,37 @@ function ProviderConnection(props: {
|
|||||||
props.setBack(goBack)
|
props.setBack(goBack)
|
||||||
|
|
||||||
function MethodSelection() {
|
function MethodSelection() {
|
||||||
|
if (newLayout())
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<For each={methods()}>
|
||||||
|
{(item, index) => {
|
||||||
|
const details = () => methodDetails(item)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => void selectMethod(index())}
|
||||||
|
>
|
||||||
|
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||||
|
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||||
|
</span>
|
||||||
|
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
||||||
|
<Show when={details().hint}>
|
||||||
|
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
@@ -531,7 +770,7 @@ function ProviderConnection(props: {
|
|||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label ?? m?.type}
|
||||||
onSelect={async (selected, index) => {
|
onSelect={async (selected, index) => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
void selectMethod(index)
|
void selectMethod(index)
|
||||||
@@ -552,11 +791,18 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ApiAuthView() {
|
function ApiAuthView() {
|
||||||
|
let apiKey: HTMLInputElement | undefined
|
||||||
|
const errorID = createUniqueId()
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!newLayout()) return
|
||||||
|
apiKey?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -570,17 +816,67 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK().api.integration.connect.key({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
auth: {
|
location: location(),
|
||||||
type: "api",
|
key: apiKey,
|
||||||
key: apiKey,
|
|
||||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newLayout())
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
<Show
|
||||||
|
when={provider().id === "opencode"}
|
||||||
|
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
|
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
|
<div>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
|
<ExternalLink
|
||||||
|
href="https://opencode.ai/zen"
|
||||||
|
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||||
|
>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
|
</ExternalLink>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
|
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
|
<TextInputV2
|
||||||
|
ref={apiKey}
|
||||||
|
class="!w-full"
|
||||||
|
name="apiKey"
|
||||||
|
data-input="provider-api-key"
|
||||||
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
|
value={formStore.value}
|
||||||
|
invalid={formStore.error !== undefined}
|
||||||
|
aria-describedby={formStore.error ? errorID : undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Show when={formStore.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</ButtonV2>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -590,9 +886,9 @@ function ProviderConnection(props: {
|
|||||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
</Link>
|
</ExternalLink>
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +901,8 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus={!newLayout()}
|
||||||
|
ref={apiKey}
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
@@ -624,11 +921,18 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OAuthCodeView() {
|
function OAuthCodeView() {
|
||||||
|
let codeInput: HTMLInputElement | undefined
|
||||||
|
const errorID = createUniqueId()
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!newLayout()) return
|
||||||
|
codeInput?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -643,12 +947,13 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.complete({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
attemptID: store.authorization!.attemptID,
|
||||||
|
location: location(),
|
||||||
code,
|
code,
|
||||||
})
|
})
|
||||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
.then(() => ({ ok: true as const }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
@@ -657,16 +962,59 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newLayout())
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
<div>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
|
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
|
</ExternalLink>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
|
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
|
<TextInputV2
|
||||||
|
ref={codeInput}
|
||||||
|
class="!w-full"
|
||||||
|
name="code"
|
||||||
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
|
value={formStore.value}
|
||||||
|
invalid={formStore.error !== undefined}
|
||||||
|
aria-describedby={formStore.error ? errorID : undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Show when={formStore.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<ButtonV2 type="submit" variant="contrast">
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</ButtonV2>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
<ExternalLink href={store.authorization!.url}>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
|
</ExternalLink>
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus={!newLayout()}
|
||||||
|
ref={codeInput}
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
@@ -694,32 +1042,46 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
const poll = async () => {
|
||||||
|
const authorization = store.authorization
|
||||||
|
if (!authorization || !alive.value) return
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.status({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
attemptID: authorization.attemptID,
|
||||||
|
location: location(),
|
||||||
})
|
})
|
||||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
.then((value) => ({ ok: true as const, status: value.data }))
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||||
dispatch({ type: "auth.error", error: message })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (result.status.status === "complete") {
|
||||||
await complete()
|
await complete()
|
||||||
})()
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "failed") {
|
||||||
|
dispatch({ type: "auth.error", error: result.status.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "expired") {
|
||||||
|
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.current = setTimeout(poll, 1_000)
|
||||||
|
}
|
||||||
|
void poll()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
<ExternalLink href={store.authorization!.url}>
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||||
|
</ExternalLink>
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -738,10 +1100,19 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
|
||||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon
|
||||||
<div class="text-16-medium text-text-strong">
|
id={props.provider}
|
||||||
|
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class={
|
||||||
|
newLayout()
|
||||||
|
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
||||||
|
: "text-16-medium text-text-strong"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||||
{language.t("provider.connect.title.anthropicProMax")}
|
{language.t("provider.connect.title.anthropicProMax")}
|
||||||
@@ -750,8 +1121,12 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
|
||||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
<div
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
tabIndex={newLayout() ? undefined : 0}
|
||||||
|
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={loading()}>
|
<Match when={loading()}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
@@ -783,15 +1158,15 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "api"}>
|
<Match when={method()?.type === "key"}>
|
||||||
<ApiAuthView />
|
<ApiAuthView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<Match when={method()?.type === "oauth"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Match when={store.authorization?.mode === "code"}>
|
||||||
<OAuthCodeView />
|
<OAuthCodeView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
<Match when={store.authorization?.mode === "auto"}>
|
||||||
<OAuthAutoView />
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomProviderForm() {
|
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -131,6 +131,7 @@ export function CustomProviderForm() {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
|
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
@@ -184,15 +185,15 @@ export function CustomProviderForm() {
|
|||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
<p class="text-14-regular text-text-base">
|
<p class="text-14-regular text-text-base">
|
||||||
{language.t("provider.custom.description.prefix")}
|
{language.t("provider.custom.description.prefix")}
|
||||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
{language.t("provider.custom.description.link")}
|
{language.t("provider.custom.description.link")}
|
||||||
</Link>
|
</ExternalLink>
|
||||||
{language.t("provider.custom.description.suffix")}
|
{language.t("provider.custom.description.suffix")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus={props.autofocus ?? true}
|
||||||
label={language.t("provider.custom.field.providerID.label")}
|
label={language.t("provider.custom.field.providerID.label")}
|
||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
|
|||||||
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
|||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.api.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
activeTreeNavigation,
|
activeTreeNavigation,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
async (): Promise<Path | undefined> => {
|
||||||
sdk.client.path
|
if ((await sdk.protocol) !== "v1") return
|
||||||
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined),
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
@@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
const [suggestions] = createResource(input, async (value) => {
|
||||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
const cleaned = cleanPickerInput(value)
|
||||||
|
const typed = cleaned.replace(/\/+$/, "")
|
||||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||||
if (!typed || typed === current) return { query: value, items: [] }
|
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.client.find
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
.then((result) => result.data ?? [])
|
const files = await sdk.api.file
|
||||||
|
.find({
|
||||||
|
location: { directory: base },
|
||||||
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
|
type: "file",
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...directories,
|
||||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
@@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.client.file
|
return sdk.api.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) =>
|
||||||
|
result.data.map((entry) => ({
|
||||||
|
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||||
|
type: entry.type,
|
||||||
|
})),
|
||||||
|
)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
@@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
{(suggestion, index) => (
|
{(suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
id={`directory-picker-v2-suggestion-${index()}`}
|
id={`directory-picker-v2-suggestion-${index()}`}
|
||||||
|
data-directory-path={suggestion.absolute}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index() === activeSuggestion()}
|
aria-selected={index() === activeSuggestion()}
|
||||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -17,6 +18,8 @@ interface DialogSelectDirectoryProps {
|
|||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECENT_PROJECT_LIMIT = 5
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
absolute: string
|
absolute: string
|
||||||
search: string
|
search: string
|
||||||
@@ -56,13 +59,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingHome = createMemo(() => !sync.data.path.home)
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingHome() ? true : undefined),
|
||||||
async () => {
|
async (): Promise<Path | undefined> => {
|
||||||
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((x) => x.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
@@ -100,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return projects
|
return projects
|
||||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||||
.slice(0, 5)
|
|
||||||
.map(({ project }) => {
|
.map(({ project }) => {
|
||||||
const row = toRow(project.worktree, home(), "recent")
|
const row = toRow(project.worktree, home(), "recent")
|
||||||
const name = project.name || getFilename(project.worktree)
|
const name = project.name || getFilename(project.worktree)
|
||||||
@@ -114,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||||
return uniqueRows([...recentProjects(), ...directoryRows])
|
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
||||||
|
const recent = recentProjects()
|
||||||
|
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
||||||
|
return uniqueRows([...visible, ...directoryRows])
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -162,7 +168,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const path = displayPickerPath(item.absolute, filter(), home())
|
const path = displayPickerPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
@@ -174,7 +180,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || toggle.isPending) return
|
if (!x || x.status === "pending" || toggle.isPending) return
|
||||||
toggle.mutate(x.name)
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (toggle.isPending) return
|
if (toggle.isPending) return
|
||||||
toggle.mutate(i.name)
|
toggle.mutate(i.name)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createSignal, onMount } from "solid-js"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
"MiMo V2.5 Free",
|
||||||
|
"Nemotron 3 Ultra Free",
|
||||||
|
"Deepseek V4 Flash Free",
|
||||||
|
"North Mini Code Free",
|
||||||
|
"Hy3 Free",
|
||||||
|
"Big Pickle",
|
||||||
|
]
|
||||||
|
|
||||||
|
function SelectModelWithoutProviders() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const models = names.map((name, index) => ({
|
||||||
|
id: name.toLowerCase().replaceAll(" ", "-"),
|
||||||
|
name,
|
||||||
|
provider: { id: "opencode", name: "OpenCode" },
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 128_000 },
|
||||||
|
capabilities: {
|
||||||
|
reasoning: index !== 5,
|
||||||
|
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const [current, setCurrent] = createSignal(models[2])
|
||||||
|
const model = {
|
||||||
|
list: () => models,
|
||||||
|
current,
|
||||||
|
set(value) {
|
||||||
|
setCurrent(models.find((item) => item.id === value?.modelID))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open select model dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Select Model",
|
||||||
|
id: "app-dialog-select-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithoutProviders = {
|
||||||
|
render: () => <SelectModelWithoutProviders />,
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
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 { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||||
|
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||||
|
|
||||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const model = props.model ?? local.model
|
const model = props.model ?? local.model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const theme = useTheme()
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
const providers = useProviders(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||||
|
const freeModels = createMemo(() => model.list().filter(isFree))
|
||||||
|
|
||||||
const openProviders = (provider?: string) => {
|
const openProviders = (provider?: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
@@ -62,111 +66,110 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
<DialogV2
|
||||||
|
fit
|
||||||
|
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
|
>
|
||||||
<DialogHeader closeLabel={language.t("common.close")}>
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||||
<ScrollView class="min-h-0 flex-1 w-full">
|
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
||||||
<div ref={listEl} class="flex min-h-full flex-col">
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<For each={model.list()}>
|
|
||||||
{(item) => (
|
|
||||||
<TooltipV2
|
|
||||||
class="w-full"
|
|
||||||
placement="right-start"
|
|
||||||
gutter={6}
|
|
||||||
openDelay={0}
|
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => selectModel(item)}
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{item.name}</span>
|
|
||||||
<Show when={isFree(item)}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.latest}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={currentKey() === modelKey(item)}>
|
|
||||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</TooltipV2>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
|
<For each={freeModels()}>
|
||||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
{(item) => (
|
||||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
<TooltipV2
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
class="w-full"
|
||||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
placement="right-start"
|
||||||
{language.t("dialog.model.unpaid.addMore.title")}
|
gutter={6}
|
||||||
</div>
|
openDelay={0}
|
||||||
</div>
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
<div class="flex w-full flex-col">
|
value={
|
||||||
<For
|
<ModelTooltip
|
||||||
each={[...providers.popular()].sort((a, b) => {
|
model={{ ...item, name: displayModelName(item.name) }}
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
latest={item.latest}
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
free={isFree(item)}
|
||||||
}
|
v2
|
||||||
return a.name.localeCompare(b.name)
|
/>
|
||||||
})}
|
}
|
||||||
>
|
>
|
||||||
{(provider) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => openProviders(provider.id)}
|
|
||||||
>
|
|
||||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
<span class="min-w-0 truncate">{provider.name}</span>
|
|
||||||
<Show when={provider.id === "opencode"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.opencode.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === "opencode-go"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === "anthropic"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.anthropic.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
onClick={() => openProviders()}
|
onClick={() => selectModel(item)}
|
||||||
>
|
>
|
||||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||||
<Icon name="dot-grid" size="small" />
|
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||||
</span>
|
<Show when={item.latest}>
|
||||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||||
{language.t("dialog.provider.viewAll")}
|
</Show>
|
||||||
</span>
|
<Show when={currentKey() === modelKey(item)}>
|
||||||
|
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
||||||
|
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
||||||
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.model.unpaid.addMore.title")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
|
<For
|
||||||
|
each={[...providers.popular()]
|
||||||
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
|
>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-provider-id={provider.id}
|
||||||
|
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||||
|
classList={{
|
||||||
|
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||||
|
theme.mode() !== "dark",
|
||||||
|
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
||||||
|
}}
|
||||||
|
onClick={() => openProviders(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
<span class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
|
<span class="truncate font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t(
|
||||||
|
provider.id === "opencode"
|
||||||
|
? "dialog.provider.opencode.tagline"
|
||||||
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
onClick={() => openProviders()}
|
||||||
|
>
|
||||||
|
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogV2>
|
</DialogV2>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import {
|
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||||
Component,
|
|
||||||
ComponentProps,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
JSX,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
ValidComponent,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
@@ -122,14 +113,13 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</Kobalte.Trigger>
|
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
@@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
|
|||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const model = props.model ?? useLocal().model
|
|
||||||
const language = useLanguage()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const controller = createModelSelectorController({
|
||||||
let searchRef: HTMLInputElement | undefined
|
model: props.model,
|
||||||
let contentRef: HTMLDivElement | undefined
|
provider: () => props.provider,
|
||||||
let restoreTrigger = true
|
onSelect: () => props.onClose?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorPopoverV2View
|
||||||
|
trigger={props.trigger}
|
||||||
|
models={controller.models}
|
||||||
|
groups={controller.groups}
|
||||||
|
current={controller.current}
|
||||||
|
select={controller.select}
|
||||||
|
onManage={() => {
|
||||||
|
void import("./dialog-manage-models").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModelSelectorController(input: {
|
||||||
|
provider: () => string | undefined
|
||||||
|
model?: ModelState
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const model = input.model ?? useLocal().model
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||||
)
|
)
|
||||||
const models = createMemo(() => {
|
|
||||||
const search = store.search.trim()
|
|
||||||
const filtered = search
|
|
||||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
: allModels()
|
|
||||||
|
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
return {
|
||||||
})
|
models: (search: string) => {
|
||||||
const groups = createMemo(() => {
|
const query = search.trim()
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
const filtered = query
|
||||||
for (const item of models()) {
|
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
: allModels()
|
||||||
}
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
},
|
||||||
})
|
groups: (models: ModelItem[]) => {
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
const current = () => {
|
for (const item of models) {
|
||||||
const value = model.current()
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
}
|
||||||
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
|
},
|
||||||
|
current: () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? modelKey(value) : undefined
|
||||||
|
},
|
||||||
|
select: (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
input.onSelect()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorPopoverV2View(props: {
|
||||||
|
trigger: ModelSelectorTrigger
|
||||||
|
models: (search: string) => ModelItem[]
|
||||||
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
|
current: () => string | undefined
|
||||||
|
select: (item: ModelItem) => void
|
||||||
|
onManage: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
|
|
||||||
|
const models = createMemo(() => props.models(store.search))
|
||||||
|
const groups = createMemo(() => props.groups(models()))
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = current()
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreTrigger = true
|
dismiss.allowTriggerRestore()
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
const select = (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
props.onClose?.()
|
|
||||||
}
|
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => select(item))
|
dismiss.afterClose(() => props.select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => {
|
dismiss.afterClose(props.onManage)
|
||||||
void import("./dialog-manage-models").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const search = value.trim()
|
const first = props.models(value)[0]
|
||||||
const first = [...allModels()]
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<MenuV2.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => props.onClose?.())
|
dismiss.afterClose(props.onClose)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
@@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={current()}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
@@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (normalized === input.original.http.url) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
@@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const raw = items()
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? raw
|
||||||
|
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const runAction = () => {
|
const runAction = () => {
|
||||||
if (props.link) platform.openLink(props.link)
|
if (props.link) platform.openExternal(props.link)
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
|||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
client: {
|
api: {
|
||||||
find: {
|
file: {
|
||||||
files: (input: { directory: string }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.directory)
|
directories.push(input.location?.directory ?? "")
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve({ data: [] })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
|||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("searches from an absolute root without a default base", async () => {
|
||||||
|
const directories: string[] = []
|
||||||
|
const sdk = {
|
||||||
|
api: {
|
||||||
|
file: {
|
||||||
|
list: (input: { location?: { directory?: string } }) => {
|
||||||
|
directories.push(input.location?.directory ?? "")
|
||||||
|
return Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{ path: "Users/", type: "directory" },
|
||||||
|
{ path: "tmp/", type: "directory" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||||
|
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||||
|
|
||||||
|
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||||
|
expect(directories).toEqual(["/"])
|
||||||
|
})
|
||||||
|
|
||||||
test("identifies the next directory level to preload", () => {
|
test("identifies the next directory level to preload", () => {
|
||||||
expect(
|
expect(
|
||||||
preloadTreeDirectories("src/", [
|
preloadTreeDirectories("src/", [
|
||||||
|
|||||||
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
|
const raw = normalizePickerDrive(value)
|
||||||
|
const root = pickerRoot(raw)
|
||||||
|
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||||
const base = args.base()
|
const base = args.base()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||||
const home = args.home()
|
const home = args.home()
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||||
const root = pickerRoot(raw)
|
|
||||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
|
||||||
return { directory: trimPickerPath(base), path: raw }
|
return { directory: trimPickerPath(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.client.file
|
const request = args.sdk.api.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.filter((node) => node.type === "directory")
|
||||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
.map((node) => {
|
||||||
|
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||||
|
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
cache.set(key, request)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
|||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.client.find
|
const results = await args.sdk.api.file
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
@@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await serverCtx().sdk.client.project.update({
|
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||||
projectID: props.project.id,
|
const project = await serverCtx()
|
||||||
directory: props.project.worktree,
|
.sdk.client.project.update({
|
||||||
name,
|
projectID: props.project.id,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
directory: props.project.worktree,
|
||||||
commands: { start },
|
name,
|
||||||
})
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
if (!project) return
|
||||||
|
// const project = await serverCtx().sdk.api.project.update({
|
||||||
|
// projectID: props.project.id,
|
||||||
|
// name,
|
||||||
|
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
// commands: { start },
|
||||||
|
// })
|
||||||
|
serverCtx().sync.set("project", (items) =>
|
||||||
|
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||||
|
)
|
||||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
|
|
||||||
|
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalLink(props: ExternalLinkProps) {
|
||||||
|
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
target={local.target ?? "_blank"}
|
||||||
|
rel={local.rel ?? "noopener noreferrer"}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,60 +8,21 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
|||||||
import homeImage from "@/assets/help/home.png"
|
import homeImage from "@/assets/help/home.png"
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
|
|
||||||
const helpIcon = (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
data-slot="icon-svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
|
||||||
fill="var(--v2-icon-icon-base)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const triggerClass =
|
|
||||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
// TODO: wire to changelog / seen-state when available
|
||||||
const showPopover = () => true
|
const showPopover = () => true
|
||||||
|
|
||||||
export function HelpButton() {
|
|
||||||
const platform = usePlatform()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href="https://opencode.ai"
|
|
||||||
aria-label="Open the OpenCode website"
|
|
||||||
data-component="icon-button-v2"
|
|
||||||
data-size="large"
|
|
||||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
platform.openLink(event.currentTarget.href)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{helpIcon}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
|
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -110,12 +71,40 @@ export function TabsInfoPopup() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<DrawerContent>
|
<DrawerContent
|
||||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
style={
|
||||||
|
windows()
|
||||||
|
? {
|
||||||
|
inset: "0 0 0 auto",
|
||||||
|
"max-height": "100vh",
|
||||||
|
"max-width": "100vw",
|
||||||
|
"border-radius": "0",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={windows()}>
|
||||||
|
<DrawerClose
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="neutral"
|
||||||
|
aria-label="Close"
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
class="absolute top-[10px] left-[-36px]"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
||||||
|
classList={{
|
||||||
|
"h-[40px] px-4": windows(),
|
||||||
|
"h-[52px] p-4": !windows(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
July 14
|
July 14
|
||||||
</p>
|
</p>
|
||||||
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
|
<Show when={!windows()}>
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
as={IconButtonV2}
|
as={IconButtonV2}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
|
|
||||||
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Link(props: LinkProps) {
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={local.href}
|
|
||||||
class={`text-text-strong underline ${local.class ?? ""}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!local.href) return
|
|
||||||
event.preventDefault()
|
|
||||||
platform.openLink(local.href)
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||||
|
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
|
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
||||||
|
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
||||||
|
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
||||||
|
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
||||||
|
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
||||||
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
|
import { useComments } from "@/context/comments"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||||
|
import {
|
||||||
|
createPromptInputV2Controller,
|
||||||
|
createPromptInputV2State,
|
||||||
|
type PromptInputV2Interaction,
|
||||||
|
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
|
||||||
|
|
||||||
|
export type PromptInputV2ComposerProps = {
|
||||||
|
class?: string
|
||||||
|
controller: PromptInputV2ComposerController
|
||||||
|
borderUnderlay?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<PromptInputV2
|
||||||
|
controller={props.controller}
|
||||||
|
borderUnderlay={props.borderUnderlay}
|
||||||
|
class={props.class}
|
||||||
|
variantControlVisible={!props.controller.model.loading}
|
||||||
|
attachKeybind={command.keybindParts("file.attach")}
|
||||||
|
attachShortcut={command.keybind("file.attach")}
|
||||||
|
modelControl={
|
||||||
|
<PromptInputV2ModelControl
|
||||||
|
loading={props.controller.model.loading}
|
||||||
|
paid={props.controller.model.paid}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybindParts("model.choose")}
|
||||||
|
model={props.controller.model.selection}
|
||||||
|
providerID={props.controller.model.selection.current()?.provider?.id}
|
||||||
|
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
onClose={props.controller.restoreFocus}
|
||||||
|
onUnpaidClick={() =>
|
||||||
|
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const files = useFile()
|
||||||
|
const layout = useLayout()
|
||||||
|
const comments = useComments()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const command = useCommand()
|
||||||
|
const permission = usePermission()
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const prompt = props.state ?? usePrompt()
|
||||||
|
let editor: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const interaction = createPromptInputV2State()
|
||||||
|
const mode = () => interaction[0].mode
|
||||||
|
const history = props.history ?? createPersistedPromptInputHistory()
|
||||||
|
const tabs = () => props.controls.session.tabs
|
||||||
|
const activeFileTab = createSessionTabs({
|
||||||
|
tabs,
|
||||||
|
pathFromTab: files.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||||
|
}).activeFileTab
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
const all = tabs().all()
|
||||||
|
const active = activeFileTab()
|
||||||
|
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
|
||||||
|
return order.reduce<string[]>((result, tab) => {
|
||||||
|
const path = files.pathFromTab(tab)
|
||||||
|
if (!path || result.includes(path)) return result
|
||||||
|
return [...result, path]
|
||||||
|
}, [])
|
||||||
|
})
|
||||||
|
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||||
|
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||||
|
const attachments = createMemo(() =>
|
||||||
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
|
)
|
||||||
|
const commentCount = createMemo(() => {
|
||||||
|
if (mode() === "shell") return 0
|
||||||
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
|
})
|
||||||
|
const blank = createMemo(() => {
|
||||||
|
const text = prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||||
|
})
|
||||||
|
const stopping = createMemo(() => working() && blank())
|
||||||
|
const placeholder = createMemo(() =>
|
||||||
|
promptPlaceholder({
|
||||||
|
mode: mode(),
|
||||||
|
commentCount: commentCount(),
|
||||||
|
example: mode() === "shell" ? "git status" : "",
|
||||||
|
suggest: false,
|
||||||
|
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
|
||||||
|
|
||||||
|
const historyComments = () => {
|
||||||
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
|
return prompt.context.items().flatMap((item) => {
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment) return []
|
||||||
|
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||||
|
const nextSelection =
|
||||||
|
selection ??
|
||||||
|
(item.selection
|
||||||
|
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
|
||||||
|
: undefined)
|
||||||
|
if (!nextSelection) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: item.commentID ?? item.key,
|
||||||
|
path: item.path,
|
||||||
|
selection: { ...nextSelection },
|
||||||
|
comment,
|
||||||
|
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||||
|
origin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
} satisfies PromptHistoryComment,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
|
||||||
|
comments.replace(
|
||||||
|
items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
file: item.path,
|
||||||
|
selection: { ...item.selection },
|
||||||
|
comment: item.comment,
|
||||||
|
time: item.time,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
prompt.context.replaceComments(
|
||||||
|
items.map((item) => ({
|
||||||
|
type: "file",
|
||||||
|
path: item.path,
|
||||||
|
selection: selectionFromLines(item.selection),
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.id,
|
||||||
|
commentOrigin: item.origin,
|
||||||
|
preview: item.preview,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const id = props.controls.session.id
|
||||||
|
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||||
|
return permission.isAutoAccepting(id, sdk().directory)
|
||||||
|
})
|
||||||
|
const submission = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info,
|
||||||
|
imageAttachments: attachments,
|
||||||
|
commentCount,
|
||||||
|
autoAccept: accepting,
|
||||||
|
mode,
|
||||||
|
working,
|
||||||
|
editor: () => editor,
|
||||||
|
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||||
|
promptLength,
|
||||||
|
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||||
|
resetHistoryNavigation: () => controller.resetHistory(),
|
||||||
|
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||||
|
setPopover: (popover) => {
|
||||||
|
if (!popover) controller.dispatch({ type: "popover.close" })
|
||||||
|
},
|
||||||
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
|
shouldQueue: props.shouldQueue,
|
||||||
|
onQueue: props.onQueue,
|
||||||
|
onAbort: props.onAbort,
|
||||||
|
onSubmit: props.onSubmit,
|
||||||
|
model: props.controls.model.selection,
|
||||||
|
})
|
||||||
|
|
||||||
|
const referenceDescription = (reference: ReferenceInfo) =>
|
||||||
|
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||||
|
const references = createMemo(() =>
|
||||||
|
sync()
|
||||||
|
.data.reference.filter((reference) => !reference.hidden)
|
||||||
|
.map((reference) => ({
|
||||||
|
id: `reference:${reference.name}`,
|
||||||
|
kind: "reference" as const,
|
||||||
|
label: `@${reference.name}`,
|
||||||
|
path: reference.path,
|
||||||
|
description: reference.description ?? referenceDescription(reference),
|
||||||
|
mention: {
|
||||||
|
type: "file" as const,
|
||||||
|
path: reference.path,
|
||||||
|
content: `@${reference.name}`,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: "application/x-directory",
|
||||||
|
filename: reference.name,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const resources = createMemo(() =>
|
||||||
|
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||||
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
|
kind: "resource" as const,
|
||||||
|
label: `@${resource.name}`,
|
||||||
|
path: resource.uri,
|
||||||
|
description: resource.description,
|
||||||
|
mention: {
|
||||||
|
type: "file" as const,
|
||||||
|
path: resource.uri,
|
||||||
|
content: `@${resource.name}`,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: resource.mimeType ?? "text/plain",
|
||||||
|
filename: resource.name,
|
||||||
|
url: resource.uri,
|
||||||
|
source: {
|
||||||
|
type: "resource" as const,
|
||||||
|
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||||
|
clientName: resource.server,
|
||||||
|
uri: resource.uri,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resource,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
||||||
|
...references(),
|
||||||
|
...props.controls.agents.available
|
||||||
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
|
.map((agent) => ({
|
||||||
|
id: `agent:${agent.name}`,
|
||||||
|
kind: "agent" as const,
|
||||||
|
label: `@${agent.name}`,
|
||||||
|
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
|
||||||
|
})),
|
||||||
|
...resources(),
|
||||||
|
...recent().map((path) => ({
|
||||||
|
id: `file:${path}`,
|
||||||
|
kind: "file" as const,
|
||||||
|
label: path,
|
||||||
|
path,
|
||||||
|
recent: true,
|
||||||
|
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
const slashCommands = createMemo(() => [
|
||||||
|
...sync().data.command.map((item) => ({
|
||||||
|
id: `custom.${item.name}`,
|
||||||
|
trigger: item.name,
|
||||||
|
title: item.name,
|
||||||
|
description: item.description,
|
||||||
|
type: "custom" as const,
|
||||||
|
})),
|
||||||
|
...command.options
|
||||||
|
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
trigger: item.slash!,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
type: "builtin" as const,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
||||||
|
slashCommands().map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
kind: "command",
|
||||||
|
label: `/${item.trigger}`,
|
||||||
|
trigger: item.trigger,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
keybind: command.keybindParts(item.id),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||||
|
const controller = createPromptInputV2Controller({
|
||||||
|
store: () => prompt.capture().store,
|
||||||
|
state: interaction,
|
||||||
|
identity: () => prompt.capture(),
|
||||||
|
history: {
|
||||||
|
entries: (mode) =>
|
||||||
|
history.entries(mode).map((value) => {
|
||||||
|
const entry = normalizePromptHistoryEntry(value)
|
||||||
|
return { prompt: entry.prompt, metadata: entry.comments }
|
||||||
|
}),
|
||||||
|
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||||
|
capture: historyComments,
|
||||||
|
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||||
|
},
|
||||||
|
commands,
|
||||||
|
context,
|
||||||
|
searchContextFiles: async (query) =>
|
||||||
|
(await files.searchFilesAndDirectories(query)).map((path) => ({
|
||||||
|
id: `file:${path}`,
|
||||||
|
kind: "file",
|
||||||
|
label: path,
|
||||||
|
path,
|
||||||
|
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
|
||||||
|
})),
|
||||||
|
onContextRemove(item) {
|
||||||
|
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||||
|
},
|
||||||
|
openAttachment: (attachment) =>
|
||||||
|
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||||
|
openContext(key) {
|
||||||
|
const item = controller.contextItem(key)
|
||||||
|
if (item) openComment(item, props, sync, layout, files, comments)
|
||||||
|
},
|
||||||
|
onEditor(element) {
|
||||||
|
editor = element as HTMLDivElement
|
||||||
|
props.ref?.(editor)
|
||||||
|
},
|
||||||
|
onSuggestionSelect(item) {
|
||||||
|
if (item.kind !== "command") return
|
||||||
|
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||||
|
if (!selected || selected.type === "custom") return
|
||||||
|
return () => command.trigger(selected.id, "slash")
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
picker: platform.openAttachmentPickerDialog,
|
||||||
|
directory: () => sdk().directory,
|
||||||
|
isDialogActive: () => !!dialog.active,
|
||||||
|
warn: () =>
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
|
}),
|
||||||
|
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
||||||
|
onError: (error) =>
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
readClipboardImage: platform.readClipboardImage,
|
||||||
|
getPathForFile: platform.getPathForFile,
|
||||||
|
store: platform.draftStore?.putBlob,
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
placeholder: designPlaceholder,
|
||||||
|
get agent() {
|
||||||
|
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||||
|
? {
|
||||||
|
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||||
|
current: () => props.controls.agents.current,
|
||||||
|
onSelect: (value: string) => props.controls.agents.select(value),
|
||||||
|
keybind: () => command.keybindParts("agent.cycle"),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||||
|
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||||
|
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
||||||
|
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
stopping,
|
||||||
|
working,
|
||||||
|
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||||
|
onStop: () => void submission.abort(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: controller.state.mode !== "normal",
|
||||||
|
onSelect: () => controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: controller.state.mode === "shell",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+e",
|
||||||
|
disabled: controller.state.mode === "normal",
|
||||||
|
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||||
|
edit.context.forEach((item) =>
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
controller.dispatch({ type: "mode.normal" })
|
||||||
|
controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return controller as PromptInputV2ComposerController
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptInputV2ModelControl(props: {
|
||||||
|
loading: boolean
|
||||||
|
paid: boolean
|
||||||
|
title: string
|
||||||
|
keybind: string[]
|
||||||
|
model: PromptInputV2ComposerController["model"]["selection"]
|
||||||
|
providerID?: string
|
||||||
|
modelName: string
|
||||||
|
onClose: () => void
|
||||||
|
onUnpaidClick: () => void
|
||||||
|
}) {
|
||||||
|
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||||
|
const content = () => (
|
||||||
|
<>
|
||||||
|
<Show when={props.providerID}>
|
||||||
|
{(providerID) => (
|
||||||
|
<ProviderIcon
|
||||||
|
id={providerID()}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span class="truncate leading-4">{props.modelName}</span>
|
||||||
|
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||||
|
<Icon name="chevron-down" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Show when={!props.loading}>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.title}
|
||||||
|
<KeybindV2 keys={props.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.paid}
|
||||||
|
fallback={
|
||||||
|
<ButtonV2
|
||||||
|
data-action="prompt-model"
|
||||||
|
data-control-type="dialog"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
|
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||||
|
style={{ height: "28px" }}
|
||||||
|
onClick={props.onUnpaidClick}
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ButtonV2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ModelSelectorPopoverV2
|
||||||
|
model={props.model}
|
||||||
|
trigger={(triggerProps) => (
|
||||||
|
<ButtonV2
|
||||||
|
{...triggerProps}
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="normal"
|
||||||
|
style={{ height: "28px" }}
|
||||||
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
|
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||||
|
data-action="prompt-model"
|
||||||
|
data-control-type="popover"
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ButtonV2>
|
||||||
|
)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openComment(
|
||||||
|
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||||
|
props: PromptInputV2ControllerProps,
|
||||||
|
sync: ReturnType<typeof useSync>,
|
||||||
|
layout: ReturnType<typeof useLayout>,
|
||||||
|
files: ReturnType<typeof useFile>,
|
||||||
|
comments: ReturnType<typeof useComments>,
|
||||||
|
) {
|
||||||
|
if (!item.commentID) return
|
||||||
|
const focus = { file: item.path, id: item.commentID }
|
||||||
|
comments.setActive(focus)
|
||||||
|
const queueFocus = (attempts = 6) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
comments.setFocus({ ...focus })
|
||||||
|
if (attempts <= 0) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const current = comments.focus()
|
||||||
|
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||||
|
const review =
|
||||||
|
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||||
|
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||||
|
if (review) {
|
||||||
|
layout.fileTree.setTab("changes")
|
||||||
|
props.controls.session.tabs.setActive("review")
|
||||||
|
queueFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layout.fileTree.setTab("all")
|
||||||
|
const tab = files.tab(item.path)
|
||||||
|
void props.controls.session.tabs.open(tab)
|
||||||
|
props.controls.session.tabs.setActive(tab)
|
||||||
|
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||||
|
}
|
||||||
@@ -30,8 +30,16 @@ function PromptInputExample() {
|
|||||||
activeTab: undefined as string | undefined,
|
activeTab: undefined as string | undefined,
|
||||||
reviewOpen: false,
|
reviewOpen: false,
|
||||||
})
|
})
|
||||||
|
const storyModel = {
|
||||||
|
id: "claude-3-7-sonnet",
|
||||||
|
name: "Claude 3.7 Sonnet",
|
||||||
|
provider: { id: "anthropic", name: "Anthropic" },
|
||||||
|
}
|
||||||
const model = {
|
const model = {
|
||||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
current: () => storyModel,
|
||||||
|
list: () => [storyModel],
|
||||||
|
visible: () => true,
|
||||||
|
set: () => {},
|
||||||
variant: {
|
variant: {
|
||||||
list: () => ["fast", "thinking"],
|
list: () => ["fast", "thinking"],
|
||||||
current: () => controls.variant,
|
current: () => controls.variant,
|
||||||
@@ -67,7 +75,6 @@ function PromptInputExample() {
|
|||||||
open: () => setControls("reviewOpen", true),
|
open: () => setControls("reviewOpen", true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
}
|
||||||
const addReviewComment = () => {
|
const addReviewComment = () => {
|
||||||
const comment = controls.comments + 1
|
const comment = controls.comments + 1
|
||||||
@@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
|
|||||||
},
|
},
|
||||||
reviewPanel: { opened: () => false, open: () => {} },
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
},
|
},
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
}
|
||||||
const state = {
|
const state = {
|
||||||
blocked: () => false,
|
blocked: () => false,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
|
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
|
||||||
import { attachmentMime } from "./files"
|
import { attachmentMime } from "./files"
|
||||||
import { normalizePaste, pasteMode } from "./paste"
|
import { normalizePaste, pasteMode } from "./paste"
|
||||||
|
|
||||||
function dataUrl(file: File, mime: string) {
|
|
||||||
return new Promise<string>((resolve) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.addEventListener("error", () => resolve(""))
|
|
||||||
reader.addEventListener("load", () => {
|
|
||||||
const value = typeof reader.result === "string" ? reader.result : ""
|
|
||||||
const idx = value.indexOf(",")
|
|
||||||
if (idx === -1) {
|
|
||||||
resolve(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
|
||||||
})
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||||
|
|
||||||
@@ -36,9 +21,10 @@ type PromptAttachmentsCoreInput = {
|
|||||||
warn?: () => void
|
warn?: () => void
|
||||||
readClipboardImage?: () => Promise<File | null>
|
readClipboardImage?: () => Promise<File | null>
|
||||||
getPathForFile?: (file: File) => string
|
getPathForFile?: (file: File) => string
|
||||||
|
draftStore?: DraftStore
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
export type PromptAttachmentsInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
@@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await dataUrl(file, mime)
|
|
||||||
if (!url) return false
|
|
||||||
|
|
||||||
const attachment: ImageAttachmentPart = {
|
const attachment: ImageAttachmentPart = {
|
||||||
type: "image",
|
type: "image",
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||||
mime,
|
mime,
|
||||||
dataUrl: url,
|
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
|
||||||
}
|
}
|
||||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||||
return true
|
return true
|
||||||
@@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
|||||||
|
|
||||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
const attachments = createPromptAttachmentsCore({
|
const attachments = createPromptAttachmentsCore({
|
||||||
...input,
|
...input,
|
||||||
|
draftStore: platform.draftStore,
|
||||||
capture: input.prompt.capture,
|
capture: input.prompt.capture,
|
||||||
warn: () => {
|
warn: () => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type ContextFile = {
|
|||||||
type BuildRequestPartsInput = {
|
type BuildRequestPartsInput = {
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
context: ContextFile[]
|
context: ContextFile[]
|
||||||
images: ImageAttachmentPart[]
|
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||||
text: string
|
text: string
|
||||||
messageID: string
|
messageID: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
const requestParts: PromptRequestPart[] = [
|
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||||
{
|
? [
|
||||||
id: Identifier.ascending("part"),
|
{
|
||||||
type: "text",
|
id: Identifier.ascending("part"),
|
||||||
text: input.text,
|
type: "text",
|
||||||
},
|
text: input.text,
|
||||||
]
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||||
const path = absolute(input.sessionDirectory, attachment.path)
|
const path = absolute(input.sessionDirectory, attachment.path)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { useLocal } from "@/context/local"
|
||||||
|
import type { Prompt, usePrompt } from "@/context/prompt"
|
||||||
|
import type { PromptInputHistory } from "./history-store"
|
||||||
|
import type { FollowupDraft } from "./submit"
|
||||||
|
|
||||||
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
|
export type PromptInputSubmission = {
|
||||||
|
abort: () => Promise<void> | void
|
||||||
|
handleSubmit: (event: Event) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputControls = {
|
||||||
|
agents: {
|
||||||
|
available: { name: string; hidden?: boolean; mode: string }[]
|
||||||
|
options: string[]
|
||||||
|
current: string
|
||||||
|
loading: boolean
|
||||||
|
visible: boolean
|
||||||
|
select: (name: string | undefined) => void
|
||||||
|
}
|
||||||
|
model: {
|
||||||
|
selection: ReturnType<typeof useLocal>["model"]
|
||||||
|
paid: boolean
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
session: {
|
||||||
|
id?: string
|
||||||
|
tabs: {
|
||||||
|
active: () => string | undefined
|
||||||
|
all: () => string[]
|
||||||
|
open: (tab: string) => void | Promise<void>
|
||||||
|
setActive: (tab: string) => void
|
||||||
|
}
|
||||||
|
reviewPanel: {
|
||||||
|
opened: () => boolean
|
||||||
|
open: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptInputProps {
|
||||||
|
class?: string
|
||||||
|
state?: PromptInputState
|
||||||
|
history?: PromptInputHistory
|
||||||
|
submission?: PromptInputSubmission
|
||||||
|
controls: PromptInputControls
|
||||||
|
ref?: (el: HTMLDivElement) => void
|
||||||
|
newSessionWorktree?: string
|
||||||
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||||
|
onEditLoaded?: () => void
|
||||||
|
shouldQueue?: () => boolean
|
||||||
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
|
onAbort?: () => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import {
|
||||||
|
clonePromptHistoryComments,
|
||||||
|
clonePromptParts,
|
||||||
|
prependHistoryEntry,
|
||||||
|
type PromptHistoryComment,
|
||||||
|
type PromptHistoryStoredEntry,
|
||||||
|
} from "./history"
|
||||||
|
|
||||||
|
export type PromptInputHistory = {
|
||||||
|
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||||
|
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||||
|
|
||||||
|
function createPromptInputHistoryStore(
|
||||||
|
normal: Store<PromptHistoryState>,
|
||||||
|
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||||
|
shell: Store<PromptHistoryState>,
|
||||||
|
setShell: SetStoreFunction<PromptHistoryState>,
|
||||||
|
): PromptInputHistory {
|
||||||
|
return {
|
||||||
|
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||||
|
add(prompt, mode, comments) {
|
||||||
|
const current = mode === "shell" ? shell : normal
|
||||||
|
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||||
|
const next = prependHistoryEntry(current.entries, prompt, comments)
|
||||||
|
if (next === current.entries) return
|
||||||
|
setCurrent("entries", next)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptInputHistory(): PromptInputHistory {
|
||||||
|
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||||
|
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||||
|
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPersistedPromptInputHistory() {
|
||||||
|
const [normal, setNormal, normalInit] = persisted(
|
||||||
|
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
||||||
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
|
)
|
||||||
|
const [shell, setShell, shellInit] = persisted(
|
||||||
|
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
||||||
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
|
)
|
||||||
|
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
return {
|
||||||
|
...history,
|
||||||
|
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||||
|
const ready = mode === "shell" ? shellInit : normalInit
|
||||||
|
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
|
||||||
|
const saved = clonePromptParts(prompt)
|
||||||
|
const metadata = clonePromptHistoryComments(comments)
|
||||||
|
void ready.then(() => history.add(saved, mode, metadata))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
|
|||||||
end: 12,
|
end: 12,
|
||||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||||
},
|
},
|
||||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
|
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||||
]
|
]
|
||||||
const copy = clonePromptParts(original)
|
const copy = clonePromptParts(original)
|
||||||
expect(copy).not.toBe(original)
|
expect(copy).not.toBe(original)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.blob.url}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
|
|||||||
@@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||||||
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
|
||||||
|
if (mode === "shell") return placeholder
|
||||||
|
return "Ask anything, / for commands, @ for context..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||||
import type { ModelSelection } from "@/context/local"
|
import type { ModelSelection } from "@/context/local"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
|
const sessionCreateInputs: Array<{
|
||||||
|
agent?: string
|
||||||
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
|
location?: { directory: string }
|
||||||
|
}> = []
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -19,9 +25,14 @@ const optimistic: Array<{
|
|||||||
const optimisticSeeded: boolean[] = []
|
const optimisticSeeded: boolean[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
|
const sentPrompts: string[] = []
|
||||||
|
const promptInputs: unknown[] = []
|
||||||
|
const sentCommands: unknown[] = []
|
||||||
|
const commands: Array<{ name: string }> = []
|
||||||
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
@@ -30,8 +41,14 @@ let variant: string | undefined
|
|||||||
let permissionServer = "server-a"
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
|
prompt: promptValue,
|
||||||
|
cursor: 0,
|
||||||
|
context: { items: [] },
|
||||||
|
})
|
||||||
const prompt = {
|
const prompt = {
|
||||||
|
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
|
||||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
cursor: () => 0,
|
cursor: () => 0,
|
||||||
@@ -56,23 +73,39 @@ const prompt = {
|
|||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
return {
|
return {
|
||||||
session: {
|
api: {
|
||||||
create: async () => {
|
session: {
|
||||||
await createSessionGate
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
createdSessions.push(directory)
|
await createSessionGate
|
||||||
return {
|
const location = input.location?.directory ?? directory
|
||||||
data: {
|
createdSessions.push(location)
|
||||||
|
sessionCreateInputs.push(input)
|
||||||
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id: `session-${createdSessions.length}`,
|
||||||
|
projectID: "project",
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
title: `New session ${createdSessions.length}`,
|
title: `New session ${createdSessions.length}`,
|
||||||
},
|
location: { directory: location },
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
prompt: async (input: unknown) => {
|
||||||
|
sentPrompts.push(directory)
|
||||||
|
promptInputs.push(input)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
command: async (input: unknown) => {
|
||||||
|
sentCommands.push(input)
|
||||||
|
},
|
||||||
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
|
sentShell.push(input)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
shell: async () => {
|
},
|
||||||
sentShell.push(directory)
|
session: {
|
||||||
return { data: undefined }
|
|
||||||
},
|
|
||||||
prompt: async () => ({ data: undefined }),
|
|
||||||
promptAsync: async () => ({ data: undefined }),
|
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
@@ -165,6 +198,7 @@ beforeAll(async () => {
|
|||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
@@ -176,7 +210,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => ({
|
||||||
data: { command: [] },
|
data: { command: commands },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: (value: {
|
||||||
@@ -203,6 +237,9 @@ beforeAll(async () => {
|
|||||||
session: {
|
session: {
|
||||||
remember: () => undefined,
|
remember: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
|
sync: async () => {
|
||||||
|
serverSessionSyncs++
|
||||||
|
},
|
||||||
},
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
@@ -244,11 +281,17 @@ beforeAll(async () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
sessionCreateInputs.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
sentPrompts.length = 0
|
||||||
|
promptInputs.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
|
commands.length = 0
|
||||||
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
@@ -257,6 +300,7 @@ beforeEach(() => {
|
|||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
permissionServer = "server-a"
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
|
serverSessionSyncs = 0
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -290,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(sessionCreateInputs).toEqual([
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-a" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-b" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(sentShell).toEqual([
|
||||||
|
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
])
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
expect(promoted).toEqual([
|
expect(promoted).toEqual([
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
@@ -412,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
expect(optimistic).toHaveLength(1)
|
||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
@@ -420,6 +481,56 @@ describe("prompt submit worktree selection", () => {
|
|||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
expect(sentPrompts).toEqual(["/repo/main"])
|
||||||
|
expect(promptInputs[0]).toMatchObject({
|
||||||
|
sessionID: "session-1",
|
||||||
|
text: "ls",
|
||||||
|
files: [],
|
||||||
|
agents: [],
|
||||||
|
})
|
||||||
|
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||||
|
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||||
|
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits slash commands through the current session API", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
variant = "high"
|
||||||
|
commands.push({ name: "review" })
|
||||||
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(sentCommands).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: expect.stringMatching(/^msg_/),
|
||||||
|
command: "review",
|
||||||
|
arguments: "staged changes",
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: "high" },
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("uses an injected model selection", async () => {
|
||||||
@@ -480,7 +591,8 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||||
|
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||||
expect(optimisticSeeded).toEqual([true])
|
expect(optimisticSeeded).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { setCursorPosition } from "./editor-dom"
|
|||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
import { blobDataUrl } from "@/utils/draft-store"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -39,7 +42,7 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: DirectorySDK["client"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
@@ -81,20 +84,24 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.command({
|
const messageID = Identifier.ascending("message")
|
||||||
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
arguments: tail.join(" "),
|
arguments: tail.join(" "),
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: {
|
||||||
variant: input.draft.variant,
|
id: input.draft.model.modelID,
|
||||||
parts: images.map((attachment) => ({
|
providerID: input.draft.model.providerID,
|
||||||
id: Identifier.ascending("part"),
|
variant: input.draft.variant,
|
||||||
type: "file" as const,
|
},
|
||||||
mime: attachment.mime,
|
files: await Promise.all(
|
||||||
url: attachment.dataUrl,
|
images.map(async (attachment) => ({
|
||||||
filename: attachment.filename,
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
})),
|
name: attachment.filename,
|
||||||
|
})),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -104,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||||
|
const encodedImages = await Promise.all(
|
||||||
|
images.map(async (attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
|
})),
|
||||||
|
)
|
||||||
const { requestParts, optimisticParts } = buildRequestParts({
|
const { requestParts, optimisticParts } = buildRequestParts({
|
||||||
prompt: input.draft.prompt,
|
prompt: input.draft.prompt,
|
||||||
context: input.draft.context,
|
context: input.draft.context,
|
||||||
images,
|
images: encodedImages,
|
||||||
text,
|
text,
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
@@ -152,13 +165,37 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.promptAsync({
|
await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: input.draft.model,
|
||||||
messageID,
|
|
||||||
parts: requestParts,
|
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
|
legacyParts: requestParts,
|
||||||
|
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
|
files: requestParts.flatMap((part) => {
|
||||||
|
if (part.type !== "file") return []
|
||||||
|
const text = part.source?.text
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
uri: part.url,
|
||||||
|
name: part.filename,
|
||||||
|
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
agents: requestParts.flatMap((part) =>
|
||||||
|
part.type === "agent"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: part.name,
|
||||||
|
mention: part.source
|
||||||
|
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -210,6 +247,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
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) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
const data = (err as { data?: { message?: string } }).data
|
const data = (err as { data?: { message?: string } }).data
|
||||||
if (data?.message) return data.message
|
if (data?.message) return data.message
|
||||||
@@ -235,9 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.client.session.abort({
|
.api.session.interrupt({ sessionID })
|
||||||
sessionID,
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,9 +400,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await client.session
|
const created = await sdk()
|
||||||
.create()
|
.api.session.create({
|
||||||
.then((x) => x.data ?? undefined)
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
|
})
|
||||||
|
.then(normalizeSessionInfo)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
@@ -450,12 +490,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const eventID = Event.ID.create()
|
||||||
.shell({
|
sdk()
|
||||||
|
.api.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: eventID,
|
||||||
|
command: text,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
command: text,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -473,23 +515,25 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const messageID = Identifier.ascending("message")
|
||||||
.command({
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
|
sdk()
|
||||||
|
.api.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent,
|
agent,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
variant,
|
files: await Promise.all(
|
||||||
parts: images.map((attachment) => ({
|
images.map(async (attachment) => ({
|
||||||
id: Identifier.ascending("part"),
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
type: "file" as const,
|
name: attachment.filename,
|
||||||
mime: attachment.mime,
|
})),
|
||||||
url: attachment.dataUrl,
|
),
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
@@ -573,7 +617,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft,
|
draft,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type PromptInputTransientState = {
|
|||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
variantOpen: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||||
@@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
variantOpen: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
variantOpen: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
|
|
||||||
export type PromptProject = {
|
export type PromptProject = {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -55,7 +56,7 @@ export function createPromptProjectController(input: {
|
|||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
let searchRef: HTMLInputElement | undefined
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
const selected = () => {
|
const current = () => {
|
||||||
const key = pathKey(input.controls().directory)
|
const key = pathKey(input.controls().directory)
|
||||||
return input
|
return input
|
||||||
.controls()
|
.controls()
|
||||||
@@ -65,6 +66,7 @@ export function createPromptProjectController(input: {
|
|||||||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const selected = () => current() ?? input.controls().available[0]
|
||||||
const projects = () => {
|
const projects = () => {
|
||||||
const search = store.search.trim().toLowerCase()
|
const search = store.search.trim().toLowerCase()
|
||||||
if (!search) return input.controls().available
|
if (!search) return input.controls().available
|
||||||
@@ -100,8 +102,8 @@ export function createPromptProjectController(input: {
|
|||||||
}
|
}
|
||||||
const select = (project: PromptProject) => {
|
const select = (project: PromptProject) => {
|
||||||
if (
|
if (
|
||||||
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
|
||||||
project.server?.key !== selected()?.server?.key
|
project.server?.key !== current()?.server?.key
|
||||||
) {
|
) {
|
||||||
input.controls().select(project.worktree, project.server?.key)
|
input.controls().select(project.worktree, project.server?.key)
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,7 @@ export function createPromptProjectController(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selected,
|
selected,
|
||||||
|
empty: () => input.controls().available.length === 0,
|
||||||
projects,
|
projects,
|
||||||
servers,
|
servers,
|
||||||
projectKey,
|
projectKey,
|
||||||
@@ -195,8 +198,8 @@ export function PromptProjectSelector(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
let triggerFrame: number | undefined
|
let triggerFrame: number | undefined
|
||||||
let restoreTrigger = true
|
|
||||||
|
|
||||||
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
const setTriggerRef = (element: HTMLButtonElement) => {
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
@@ -219,25 +222,15 @@ export function PromptProjectSelector(props: {
|
|||||||
props.controller.active()
|
props.controller.active()
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
: undefined
|
: undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const selectProject = (project: PromptProject) => {
|
const selectProject = (project: PromptProject) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.select(project))
|
dismiss.afterClose(() => props.controller.select(project))
|
||||||
}
|
}
|
||||||
const selectAction = (server?: string) => {
|
const selectAction = (server?: string) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
props.controller.setOpen(false)
|
props.controller.setOpen(false)
|
||||||
afterClose(() => props.controller.add(server))
|
dismiss.afterClose(() => props.controller.add(server))
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const project = props.controller.activeProject()
|
const project = props.controller.activeProject()
|
||||||
@@ -265,7 +258,7 @@ export function PromptProjectSelector(props: {
|
|||||||
)
|
)
|
||||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||||
.findLast((element) => element.offsetParent !== null)
|
.findLast((element) => element.offsetParent !== null)
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
target?.focus()
|
target?.focus()
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (props.controller.open()) props.controller.setOpen(false)
|
if (props.controller.open()) props.controller.setOpen(false)
|
||||||
@@ -289,7 +282,10 @@ export function PromptProjectSelector(props: {
|
|||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => {
|
||||||
|
if (open) dismiss.allowTriggerRestore()
|
||||||
|
props.controller.setOpen(open)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
@@ -298,11 +294,9 @@ export function PromptProjectSelector(props: {
|
|||||||
id="prompt-project-menu"
|
id="prompt-project-menu"
|
||||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
@@ -362,41 +356,45 @@ export function PromptProjectSelector(props: {
|
|||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<div class="max-h-[224px] overflow-y-auto">
|
||||||
when={props.controller.servers().length > 1}
|
<Show
|
||||||
fallback={
|
when={props.controller.servers().length > 1}
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
fallback={
|
||||||
<For each={props.controller.projects()}>
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
{(project) => (
|
<For each={props.controller.projects()}>
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
{(project) => (
|
||||||
)}
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
</For>
|
)}
|
||||||
</DropdownMenu.RadioGroup>
|
</For>
|
||||||
}
|
</DropdownMenu.RadioGroup>
|
||||||
>
|
}
|
||||||
<For
|
|
||||||
each={props.controller
|
|
||||||
.servers()
|
|
||||||
.filter((server) =>
|
|
||||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(server) => (
|
<For
|
||||||
<div>
|
each={props.controller
|
||||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
.servers()
|
||||||
{server!.name}
|
.filter((server) =>
|
||||||
|
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(server) => (
|
||||||
|
<div>
|
||||||
|
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||||
|
{server!.name}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
|
<For
|
||||||
|
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
|
||||||
|
>
|
||||||
|
{(project) => (
|
||||||
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
)}
|
||||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
</For>
|
||||||
{(project) => (
|
</Show>
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
</div>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</DropdownMenu.RadioGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-px bg-v2-border-border-muted" />
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
|
|||||||
@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
</MenuV2>
|
</MenuV2>
|
||||||
<Show when={props.branch}>
|
<PromptGitStatus branch={props.branch} />
|
||||||
{(branch) => (
|
|
||||||
<>
|
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
value={branch()}
|
|
||||||
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">{branch()}</span>
|
|
||||||
</div>
|
|
||||||
</TooltipV2>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const label = () => {
|
||||||
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
|
return props.branch
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={label()}>
|
||||||
|
{(value) => (
|
||||||
|
<>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
value={value()}
|
||||||
|
class="min-w-0 max-w-[220px]"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const key = ServerConnection.key(props.server)
|
const key = ServerConnection.key(props.server)
|
||||||
const builtin = ServerConnection.builtin(props.server)
|
return (
|
||||||
const isDefault = () => props.controller.defaultKey() === key
|
<ServerRowMenuView
|
||||||
|
server={props.server}
|
||||||
|
labels={serverMenuLabels(language)}
|
||||||
|
canDefault={props.controller.canDefault()}
|
||||||
|
isDefault={props.controller.defaultKey() === key}
|
||||||
|
onEdit={props.onEdit}
|
||||||
|
onSetDefault={() => props.controller.setDefault(key)}
|
||||||
|
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||||
|
onRemove={() => props.controller.handleRemove(key)}
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||||
|
return {
|
||||||
|
more: language.t("common.moreOptions"),
|
||||||
|
server: language.t("settings.section.server"),
|
||||||
|
edit: language.t("dialog.server.menu.edit"),
|
||||||
|
default: language.t("dialog.server.menu.default"),
|
||||||
|
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||||
|
delete: language.t("dialog.server.menu.delete"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerRowMenuView: Component<{
|
||||||
|
server: ServerConnection.Any
|
||||||
|
labels: ReturnType<typeof serverMenuLabels>
|
||||||
|
canDefault: boolean
|
||||||
|
isDefault: boolean
|
||||||
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
|
onSetDefault: () => void
|
||||||
|
onRemoveDefault: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}> = (props) => {
|
||||||
|
const builtin = () => ServerConnection.builtin(props.server)
|
||||||
|
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||||
return (
|
return (
|
||||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
|
|||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconV2 name="outline-dots" />}
|
icon={<IconV2 name="outline-dots" />}
|
||||||
aria-label={language.t("common.moreOptions")}
|
aria-label={props.labels.more}
|
||||||
/>
|
/>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content>
|
<MenuV2.Content>
|
||||||
<MenuV2.Group>
|
<MenuV2.Group>
|
||||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||||
<MenuV2.Item
|
<MenuV2.Item
|
||||||
disabled={builtin || props.server.type !== "http"}
|
disabled={builtin() || !httpServer()}
|
||||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
onSelect={() => {
|
||||||
|
const server = httpServer()
|
||||||
|
if (server) props.onEdit(server)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.menu.edit")}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
<Show when={props.canDefault && !props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && isDefault()}>
|
<Show when={props.canDefault && props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||||
{language.t("dialog.server.menu.delete")}
|
{props.labels.delete}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</MenuV2.Group>
|
</MenuV2.Group>
|
||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
|
|||||||
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
export { NewSessionView } from "./session-new-view"
|
export { NewSessionView } from "./session-new-view"
|
||||||
export { NewSessionDesignView } from "./session-new-design-view"
|
|
||||||
|
|||||||
@@ -289,9 +289,9 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={search() && centerMount()}>
|
<Show when={search() && centerMount()} keyed>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -308,10 +308,10 @@ export function SessionHeader() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hotkey()}>
|
<Show when={hotkey()} keyed>
|
||||||
{(keybind) => (
|
{(keybind) => (
|
||||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||||
{keybind()}
|
{keybind}
|
||||||
</Keybind>
|
</Keybind>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -319,9 +319,9 @@ export function SessionHeader() {
|
|||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={rightMount()}>
|
<Show when={rightMount()} keyed>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount}>
|
||||||
<Show
|
<Show
|
||||||
when={isV2}
|
when={isV2}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { JSX } from "solid-js"
|
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|
||||||
|
|
||||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
|
||||||
return (
|
|
||||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
|
||||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
|
||||||
<div class="mt-8">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -19,6 +20,7 @@ export function SortableTabV2(props: {
|
|||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||||
const sortable = useSortable({
|
const sortable = useSortable({
|
||||||
get id() {
|
get id() {
|
||||||
return props.tab
|
return props.tab
|
||||||
@@ -39,9 +41,15 @@ export function SortableTabV2(props: {
|
|||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.tab}
|
value={props.tab}
|
||||||
closeButton={
|
closeButton={
|
||||||
<TooltipKeybind
|
<TooltipV2
|
||||||
title={language.t("common.closeTab")}
|
value={
|
||||||
keybind={command.keybind("tab.close")}
|
<>
|
||||||
|
{language.t("common.closeTab")}
|
||||||
|
<Show when={closeTabKeybind().length > 0}>
|
||||||
|
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
gutter={10}
|
gutter={10}
|
||||||
>
|
>
|
||||||
@@ -52,7 +60,7 @@ export function SortableTabV2(props: {
|
|||||||
onClick={() => props.onTabClose(props.tab)}
|
onClick={() => props.onTabClose(props.tab)}
|
||||||
aria-label={language.t("common.closeTab")}
|
aria-label={language.t("common.closeTab")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
}
|
}
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { ExternalLink } from "./external-link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
async () => {
|
||||||
serverSdk()
|
const sdk = serverSdk()
|
||||||
.client.pty.shells()
|
if ((await sdk.protocol) === "v1") {
|
||||||
.then((res) => res.data ?? [])
|
return (await sdk.client.pty.shells()).data ?? []
|
||||||
.catch(() => [] as ShellOption[]),
|
}
|
||||||
|
// return (await sdk.api.pty.shells()).data
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -479,7 +482,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
@@ -20,7 +20,6 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
|
|||||||
|
|
||||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
|
||||||
|
|
||||||
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
||||||
|
|
||||||
@@ -31,6 +30,8 @@ type KeybindMeta = {
|
|||||||
|
|
||||||
type KeybindMap = Record<string, string | undefined>
|
type KeybindMap = Record<string, string | undefined>
|
||||||
type CommandContext = ReturnType<typeof useCommand>
|
type CommandContext = ReturnType<typeof useCommand>
|
||||||
|
type LanguageContext = ReturnType<typeof useLanguage>
|
||||||
|
type SettingsContext = ReturnType<typeof useSettings>
|
||||||
|
|
||||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
|
|||||||
return value as KeybindMap
|
return value as KeybindMap
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
|
||||||
const out = new Map<string, KeybindMeta>()
|
const out = new Map<string, KeybindMeta>()
|
||||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||||
|
|
||||||
@@ -263,7 +264,274 @@ function useKeyCapture(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createKeybindSettingsController(
|
||||||
|
input: {
|
||||||
|
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||||
|
settings: {
|
||||||
|
current: { keybinds: unknown }
|
||||||
|
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
|
||||||
|
}
|
||||||
|
target?: Document
|
||||||
|
notify?: (toast: { title: string; description: string }) => void
|
||||||
|
},
|
||||||
|
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
|
||||||
|
) {
|
||||||
|
const [store, setStore] = createStore({ active: null as string | null })
|
||||||
|
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
|
||||||
|
const list = createMemo(() => {
|
||||||
|
language.locale()
|
||||||
|
return listFor(input.command, overrides(), language.t("command.palette"))
|
||||||
|
})
|
||||||
|
const grouped = createMemo(() => groupedFor(list()))
|
||||||
|
const title = (id: string) => list().get(id)?.title ?? ""
|
||||||
|
const effective = (id: string) => {
|
||||||
|
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
|
||||||
|
|
||||||
|
const custom = input.settings.keybinds.get(id)
|
||||||
|
if (typeof custom === "string") return custom
|
||||||
|
|
||||||
|
const live = input.command.options.find((item) => item.id === id)
|
||||||
|
if (live?.keybind) return live.keybind
|
||||||
|
return input.command.catalog.find((item) => item.id === id)?.keybind
|
||||||
|
}
|
||||||
|
const used = createMemo(() => {
|
||||||
|
const value = new Map<string, { id: string; title: string }[]>()
|
||||||
|
|
||||||
|
for (const id of list().keys()) {
|
||||||
|
for (const signature of signatures(effective(id))) {
|
||||||
|
const items = value.get(signature)
|
||||||
|
if (items) {
|
||||||
|
items.push({ id, title: title(id) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value.set(signature, [{ id, title: title(id) }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
const stop = () => {
|
||||||
|
if (!store.active) return
|
||||||
|
setStore("active", null)
|
||||||
|
input.command.keybinds(true)
|
||||||
|
}
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
if (store.active === id) {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.active) stop()
|
||||||
|
setStore("active", id)
|
||||||
|
input.command.keybinds(false)
|
||||||
|
}
|
||||||
|
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
|
||||||
|
|
||||||
|
const handle = (event: KeyboardEvent) => {
|
||||||
|
const id = store.active
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear =
|
||||||
|
(event.key === "Backspace" || event.key === "Delete") &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
if (clear) {
|
||||||
|
input.settings.keybinds.set(id, "none")
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = recordKeybind(event)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const conflicts = new Map<string, string>()
|
||||||
|
for (const signature of signatures(next)) {
|
||||||
|
for (const item of used().get(signature) ?? []) {
|
||||||
|
if (item.id === id) continue
|
||||||
|
conflicts.set(item.id, item.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.size > 0) {
|
||||||
|
notify({
|
||||||
|
title: language.t("settings.shortcuts.conflict.title"),
|
||||||
|
description: language.t("settings.shortcuts.conflict.description", {
|
||||||
|
keybind: formatKeybind(next, language.t),
|
||||||
|
titles: [...conflicts.values()].join(", "),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.settings.keybinds.set(id, next)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = input.target ?? (typeof document === "object" ? document : undefined)
|
||||||
|
if (target) makeEventListener(target, "keydown", handle, { capture: true })
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (store.active) input.command.keybinds(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog: {
|
||||||
|
groups: GROUPS,
|
||||||
|
filtered: (query: string) =>
|
||||||
|
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
|
||||||
|
title,
|
||||||
|
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
|
||||||
|
},
|
||||||
|
capture: {
|
||||||
|
active: () => store.active,
|
||||||
|
toggle,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
|
||||||
|
reset: () => {
|
||||||
|
stop()
|
||||||
|
input.settings.keybinds.resetAll()
|
||||||
|
notify({
|
||||||
|
title: language.t("settings.shortcuts.reset.toast.title"),
|
||||||
|
description: language.t("settings.shortcuts.reset.toast.description"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsKeybindsV2() {
|
||||||
|
const command = useCommand()
|
||||||
|
const settings = useSettings()
|
||||||
|
const controller = createKeybindSettingsController({
|
||||||
|
command,
|
||||||
|
settings,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsKeybindsV2View
|
||||||
|
groups={controller.catalog.groups}
|
||||||
|
filtered={controller.catalog.filtered}
|
||||||
|
title={controller.catalog.title}
|
||||||
|
keybind={controller.catalog.keybind}
|
||||||
|
active={controller.capture.active}
|
||||||
|
onCapture={controller.capture.toggle}
|
||||||
|
hasOverrides={controller.settings.hasOverrides}
|
||||||
|
onReset={controller.settings.reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsKeybindsV2View(props: {
|
||||||
|
groups: KeybindGroup[]
|
||||||
|
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||||
|
title: (id: string) => string
|
||||||
|
keybind: (id: string) => string
|
||||||
|
active: () => string | null
|
||||||
|
onCapture: (id: string) => void
|
||||||
|
hasOverrides: () => boolean
|
||||||
|
onReset: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
|
const filtered = createMemo(() => props.filtered(store.filter))
|
||||||
|
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||||
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-search">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
|
value={store.filter}
|
||||||
|
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||||
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||||
|
/>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => setStore("filter", "")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-shortcuts flex flex-col gap-8">
|
||||||
|
<For each={props.groups}>
|
||||||
|
{(group) => (
|
||||||
|
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<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>{props.title(id)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-keybind-id={id}
|
||||||
|
classList={{
|
||||||
|
"settings-v2-keybind-button": true,
|
||||||
|
"settings-v2-keybind-button--active": props.active() === id,
|
||||||
|
}}
|
||||||
|
onClick={() => props.onCapture(id)}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.active() === id}
|
||||||
|
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
|
>
|
||||||
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={store.filter && !hasResults()}>
|
||||||
|
<div class="settings-v2-shortcuts-status">
|
||||||
|
<span>{language.t("settings.shortcuts.search.empty")}</span>
|
||||||
|
<span class="settings-v2-shortcuts-status-filter">"{store.filter}"</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
|
if (props.v2) return <SettingsKeybindsV2 />
|
||||||
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -477,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
when={props.v2}
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
fallback={
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
</ButtonV2>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-tab-search">
|
|
||||||
<TextInputV2
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
type="search"
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
appearance="base"
|
<TextField
|
||||||
|
variant="ghost"
|
||||||
|
type="text"
|
||||||
value={store.filter}
|
value={store.filter}
|
||||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
onChange={(v) => setStore("filter", v)}
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<Show when={store.filter}>
|
<Show when={store.filter}>
|
||||||
<IconButtonV2
|
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
class="settings-v2-tab-search-clear"
|
|
||||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
|
||||||
onClick={() => setStore("filter", "")}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-tab-body">{groups}</div>
|
</div>
|
||||||
</>
|
{groups}
|
||||||
</Show>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
@@ -39,8 +39,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders(() => undefined)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
const canDisconnect = (item: ProviderItem) =>
|
||||||
|
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
@@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div
|
<Show when={protocol() === "v1"}>
|
||||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
<div
|
||||||
data-component="custom-provider-section"
|
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||||
>
|
data-component="custom-provider-section"
|
||||||
<div class="flex flex-col min-w-0">
|
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
||||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
|
||||||
</div>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("settings.providers.custom.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="secondary"
|
|
||||||
icon="plus-small"
|
|
||||||
onClick={() => {
|
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{language.t("common.connect")}
|
<div class="flex flex-col min-w-0">
|
||||||
</Button>
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
</div>
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||||
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
|
</div>
|
||||||
|
<span class="text-12-regular text-text-weak pl-8">
|
||||||
|
{language.t("settings.providers.custom.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus-small"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, createSignal, startTransition } from "solid-js"
|
import { Component, createMemo, createSignal, startTransition } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
|
|||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const layout = useLayout()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const serverSync = useServerSync()
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
|
const directory = createMemo(() => {
|
||||||
|
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 serverSync().session.get(route.sessionId)?.directory
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const showProviders = () => {
|
const showProviders = () => {
|
||||||
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
||||||
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
|
|||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 onBack={showProviders} />
|
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
<SettingsModelsV2 />
|
<SettingsModelsV2 />
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { onCleanup } from "solid-js"
|
||||||
|
|
||||||
|
export type ShellOption = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
acceptable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShellSelectOption = {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
name: string
|
||||||
|
terminalOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
|
||||||
|
const counts = input.shells.reduce((result, shell) => {
|
||||||
|
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
|
||||||
|
return result
|
||||||
|
}, new Map<string, number>())
|
||||||
|
const options: ShellSelectOption[] = [
|
||||||
|
{ id: "auto", value: "", name: "", terminalOnly: false },
|
||||||
|
...input.shells.map((shell) => {
|
||||||
|
const ambiguous = (counts.get(shell.name) ?? 0) > 1
|
||||||
|
const name = ambiguous ? shell.path : shell.name
|
||||||
|
return {
|
||||||
|
id: shell.path,
|
||||||
|
value: ambiguous ? shell.path : shell.name,
|
||||||
|
name,
|
||||||
|
terminalOnly: !shell.acceptable,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
if (input.current && !options.some((option) => option.value === input.current)) {
|
||||||
|
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
|
||||||
|
let cleanup: (() => void) | undefined
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let run = 0
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
run += 1
|
||||||
|
cleanup?.()
|
||||||
|
clearTimeout(timeout)
|
||||||
|
cleanup = undefined
|
||||||
|
timeout = undefined
|
||||||
|
}
|
||||||
|
const play = (id: string | undefined) => {
|
||||||
|
stop()
|
||||||
|
if (!id) return
|
||||||
|
const current = ++run
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = undefined
|
||||||
|
void player(id).then((next) => {
|
||||||
|
if (run === current) {
|
||||||
|
cleanup = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next?.()
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(stop)
|
||||||
|
return { play, stop }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, test, vi } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||||
|
|
||||||
|
describe("settings v2 controllers", () => {
|
||||||
|
test("normalizes shell names and preserves an unavailable configured shell", () => {
|
||||||
|
expect(
|
||||||
|
createShellOptions({
|
||||||
|
shells: [
|
||||||
|
{ path: "/bin/bash", name: "bash", acceptable: true },
|
||||||
|
{ path: "/opt/bash", name: "bash", acceptable: false },
|
||||||
|
{ path: "/bin/zsh", name: "zsh", acceptable: true },
|
||||||
|
],
|
||||||
|
current: "fish",
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ id: "auto", value: "", name: "", terminalOnly: false },
|
||||||
|
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
|
||||||
|
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
|
||||||
|
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
|
||||||
|
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("debounces previews and stops owned audio on disposal", async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const played: string[] = []
|
||||||
|
const stopped: string[] = []
|
||||||
|
const owned = createRoot((dispose) => ({
|
||||||
|
dispose,
|
||||||
|
preview: createSoundPreviewController(async (id) => {
|
||||||
|
played.push(id ?? "")
|
||||||
|
return () => stopped.push(id ?? "")
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
owned.preview.play("first")
|
||||||
|
vi.advanceTimersByTime(99)
|
||||||
|
expect(played).toEqual([])
|
||||||
|
|
||||||
|
owned.preview.play("second")
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(played).toEqual(["second"])
|
||||||
|
|
||||||
|
owned.dispose()
|
||||||
|
expect(stopped).toEqual(["second"])
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||||
|
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import {
|
||||||
|
monoDefault,
|
||||||
|
monoFontFamily,
|
||||||
|
monoInput,
|
||||||
|
sansDefault,
|
||||||
|
sansFontFamily,
|
||||||
|
sansInput,
|
||||||
|
terminalDefault,
|
||||||
|
terminalFontFamily,
|
||||||
|
terminalInput,
|
||||||
|
useSettings,
|
||||||
|
} from "@/context/settings"
|
||||||
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
|
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
||||||
|
|
||||||
|
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||||
|
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
||||||
|
|
||||||
|
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
||||||
|
const permission = usePermission()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const directory = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return undefined
|
||||||
|
return serverSync().session.lineage.peek(id)?.session.directory
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accepting: createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
const dir = directory()
|
||||||
|
if (!id || !dir) return false
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShellSettingsController() {
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const [shells] = createResource(
|
||||||
|
async () => {
|
||||||
|
const sdk = serverSdk()
|
||||||
|
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
|
||||||
|
return [] as ShellOption[]
|
||||||
|
},
|
||||||
|
{ initialValue: [] as ShellOption[] },
|
||||||
|
)
|
||||||
|
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
shells: () => shells.latest,
|
||||||
|
current,
|
||||||
|
select: (value: string) => {
|
||||||
|
if (value === current()) return
|
||||||
|
void serverSync().updateConfig({ shell: value })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppearanceSettingsController() {
|
||||||
|
const settings = useSettings()
|
||||||
|
const theme = useTheme()
|
||||||
|
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
|
onMount(() => void theme.loadThemes())
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheme: {
|
||||||
|
current: theme.colorScheme,
|
||||||
|
select: (value: ColorScheme) => theme.setColorScheme(value),
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
options: themes,
|
||||||
|
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
|
||||||
|
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
ui: createMemo(() => ({
|
||||||
|
value: sansInput(settings.appearance.uiFont()),
|
||||||
|
family: sansFontFamily(settings.appearance.uiFont()),
|
||||||
|
placeholder: sansDefault,
|
||||||
|
})),
|
||||||
|
code: createMemo(() => ({
|
||||||
|
value: monoInput(settings.appearance.font()),
|
||||||
|
family: monoFontFamily(settings.appearance.font()),
|
||||||
|
placeholder: monoDefault,
|
||||||
|
})),
|
||||||
|
terminal: createMemo(() => ({
|
||||||
|
value: terminalInput(settings.appearance.terminalFont()),
|
||||||
|
family: terminalFontFamily(settings.appearance.terminalFont()),
|
||||||
|
placeholder: terminalDefault,
|
||||||
|
})),
|
||||||
|
setUI: (value: string) => settings.appearance.setUIFont(value),
|
||||||
|
setCode: (value: string) => settings.appearance.setFont(value),
|
||||||
|
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
|
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
|
export type SoundSelectOption = (typeof soundOptions)[number]
|
||||||
|
|
||||||
|
export function createSoundSettingsController() {
|
||||||
|
const settings = useSettings()
|
||||||
|
const preview = createSoundPreviewController(playSoundById)
|
||||||
|
const channel = (
|
||||||
|
enabled: Accessor<boolean>,
|
||||||
|
current: Accessor<string>,
|
||||||
|
setEnabled: (value: boolean) => void,
|
||||||
|
set: (id: string) => void,
|
||||||
|
) => ({
|
||||||
|
current: createMemo(() =>
|
||||||
|
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
|
||||||
|
),
|
||||||
|
highlight: (option: SoundSelectOption | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
preview.play(option.id === "none" ? undefined : option.id)
|
||||||
|
},
|
||||||
|
select: (option: SoundSelectOption | null) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.id === "none") {
|
||||||
|
setEnabled(false)
|
||||||
|
preview.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEnabled(true)
|
||||||
|
set(option.id)
|
||||||
|
preview.play(option.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: channel(
|
||||||
|
settings.sounds.agentEnabled,
|
||||||
|
settings.sounds.agent,
|
||||||
|
(value) => settings.sounds.setAgentEnabled(value),
|
||||||
|
(id) => settings.sounds.setAgent(id),
|
||||||
|
),
|
||||||
|
permissions: channel(
|
||||||
|
settings.sounds.permissionsEnabled,
|
||||||
|
settings.sounds.permissions,
|
||||||
|
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||||
|
(id) => settings.sounds.setPermissions(id),
|
||||||
|
),
|
||||||
|
errors: channel(
|
||||||
|
settings.sounds.errorsEnabled,
|
||||||
|
settings.sounds.errors,
|
||||||
|
(value) => settings.sounds.setErrorsEnabled(value),
|
||||||
|
(id) => settings.sounds.setErrors(id),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
|
||||||
|
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||||
|
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||||
|
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||||
@@ -1,179 +1,297 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
import { Component, Show, createMemo, createResource } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import { useSettings } from "@/context/settings"
|
||||||
monoDefault,
|
import { ExternalLink } from "../external-link"
|
||||||
monoFontFamily,
|
|
||||||
monoInput,
|
|
||||||
sansDefault,
|
|
||||||
sansFontFamily,
|
|
||||||
sansInput,
|
|
||||||
terminalDefault,
|
|
||||||
terminalFontFamily,
|
|
||||||
terminalInput,
|
|
||||||
useSettings,
|
|
||||||
} from "@/context/settings"
|
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|
||||||
import { Link } from "../link"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
|
import {
|
||||||
|
createAppearanceSettingsController,
|
||||||
|
createPermissionScopeController,
|
||||||
|
createShellOptions,
|
||||||
|
createShellSettingsController,
|
||||||
|
createSoundSettingsController,
|
||||||
|
soundOptions,
|
||||||
|
type AppearanceSettingsController,
|
||||||
|
type PermissionScopeController,
|
||||||
|
type ShellSettingsController,
|
||||||
|
type SoundSettingsController,
|
||||||
|
} from "./general-controllers"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
let demoSoundState = {
|
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||||
cleanup: undefined as (() => void) | undefined,
|
const fontSettings = {
|
||||||
timeout: undefined as NodeJS.Timeout | undefined,
|
ui: {
|
||||||
run: 0,
|
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 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 PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
return (
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-auto-accept-permissions">
|
||||||
|
<Switch
|
||||||
|
checked={props.controller.accepting()}
|
||||||
|
disabled={!props.controller.enabled()}
|
||||||
|
onChange={props.controller.set}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThemeOption = {
|
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||||
id: string
|
const language = useLanguage()
|
||||||
name: string
|
const options = createMemo(() =>
|
||||||
|
createShellOptions({
|
||||||
|
shells: props.controller.shells(),
|
||||||
|
current: props.controller.current(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.shell.title")}
|
||||||
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-shell"
|
||||||
|
options={options()}
|
||||||
|
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => {
|
||||||
|
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
|
||||||
|
if (!option.terminalOnly) return option.name
|
||||||
|
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||||
|
}}
|
||||||
|
onSelect={(option) => option && props.controller.select(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShellOption = {
|
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||||
path: string
|
const language = useLanguage()
|
||||||
name: string
|
return (
|
||||||
acceptable: boolean
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||||
|
<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 === props.controller.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 && props.controller.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={props.controller.theme.options()}
|
||||||
|
current={props.controller.theme.current()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.name}
|
||||||
|
onSelect={props.controller.theme.select}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<FontSetting kind="ui" fonts={props.controller.fonts} />
|
||||||
|
<FontSetting kind="code" fonts={props.controller.fonts} />
|
||||||
|
<FontSetting kind="terminal" fonts={props.controller.fonts} />
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShellSelectOption = {
|
const FontSetting: Component<{
|
||||||
id: string
|
kind: "ui" | "code" | "terminal"
|
||||||
value: string
|
fonts: AppearanceSettingsController["fonts"]
|
||||||
label: string
|
}> = (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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
const language = useLanguage()
|
||||||
const stopDemoSound = () => {
|
return (
|
||||||
demoSoundState.run += 1
|
<div class="settings-v2-section">
|
||||||
if (demoSoundState.cleanup) {
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||||
demoSoundState.cleanup()
|
<SettingsListV2>
|
||||||
}
|
<SoundSetting kind="agent" channel={props.controller.agent} />
|
||||||
clearTimeout(demoSoundState.timeout)
|
<SoundSetting kind="permissions" channel={props.controller.permissions} />
|
||||||
demoSoundState.cleanup = undefined
|
<SoundSetting kind="errors" channel={props.controller.errors} />
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const playDemoSound = (id: string | undefined) => {
|
const SoundSetting: Component<{
|
||||||
stopDemoSound()
|
kind: "agent" | "permissions" | "errors"
|
||||||
if (!id) return
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const run = ++demoSoundState.run
|
const LanguageSetting = () => {
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
const language = useLanguage()
|
||||||
void playSoundById(id).then((cleanup) => {
|
const options = createMemo(() =>
|
||||||
if (demoSoundState.run !== run) {
|
language.locales.map((locale) => ({
|
||||||
cleanup?.()
|
value: locale,
|
||||||
return
|
label: language.label(locale),
|
||||||
}
|
})),
|
||||||
demoSoundState.cleanup = cleanup
|
)
|
||||||
})
|
return (
|
||||||
}, 100)
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.language.title")}
|
||||||
|
description={language.t("settings.general.row.language.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-language"
|
||||||
|
options={options()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
current={options().find((option) => option.value === language.locale())}
|
||||||
|
value={(option) => option.value}
|
||||||
|
label={(option) => option.label}
|
||||||
|
onSelect={(option) => option && language.setLocale(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsGeneralV2: Component<{
|
export const SettingsGeneralV2: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const theme = useTheme()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
const dir = createMemo(() => {
|
const shell = createShellSettingsController()
|
||||||
if (!props.sessionID) return undefined
|
const appearance = createAppearanceSettingsController()
|
||||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
const sounds = createSoundSettingsController()
|
||||||
})
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value || !props.sessionID) return false
|
|
||||||
return permission.isAutoAccepting(props.sessionID, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value || !props.sessionID) return
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
permission.enableAutoAccept(props.sessionID, value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.disableAutoAccept(props.sessionID, value)
|
|
||||||
}
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
|
||||||
|
|
||||||
const [shells] = createResource(
|
|
||||||
() =>
|
|
||||||
serverSdk()
|
|
||||||
.client.pty.shells()
|
|
||||||
.then((res) => res.data ?? [])
|
|
||||||
.catch(() => [] as ShellOption[]),
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
() => desktop() && "getPinchZoomEnabled" in platform,
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
{ initialValue: false },
|
{ initialValue: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void theme.loadThemes()
|
|
||||||
})
|
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
|
||||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
|
||||||
const list = shells.latest
|
|
||||||
const current = serverSync().data.config.shell
|
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
|
||||||
for (const s of list) {
|
|
||||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
autoOption,
|
|
||||||
...list.map((s) => {
|
|
||||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
|
||||||
const text = ambiguousName ? s.path : s.name
|
|
||||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
|
||||||
return {
|
|
||||||
id: s.path,
|
|
||||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
|
||||||
value: ambiguousName ? s.path : s.name,
|
|
||||||
label,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (current && !options.some((o) => o.value === current)) {
|
|
||||||
options.push({ id: current, value: current, label: current })
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
})
|
|
||||||
|
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
setPinchZoom(checked)
|
setPinchZoom(checked)
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
@@ -181,52 +299,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
void update.catch(() => setPinchZoom(!checked))
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
|
||||||
{ value: "light", label: language.t("theme.scheme.light") },
|
|
||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
|
||||||
language.locales.map((locale) => ({
|
|
||||||
value: locale,
|
|
||||||
label: language.label(locale),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
|
||||||
const mono = () => monoInput(settings.appearance.font())
|
|
||||||
const sans = () => sansInput(settings.appearance.uiFont())
|
|
||||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
|
||||||
|
|
||||||
const soundSelectProps = (
|
|
||||||
enabled: () => boolean,
|
|
||||||
current: () => string,
|
|
||||||
setEnabled: (value: boolean) => void,
|
|
||||||
set: (id: string) => void,
|
|
||||||
) => ({
|
|
||||||
options: soundOptions,
|
|
||||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
|
||||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
|
||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
|
||||||
},
|
|
||||||
onSelect: (option: (typeof soundOptions)[number] | null) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.id === "none") {
|
|
||||||
setEnabled(false)
|
|
||||||
stopDemoSound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnabled(true)
|
|
||||||
set(option.id)
|
|
||||||
playDemoSound(option.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
const InterfaceSection = () => (
|
||||||
<LayoutTransitionToggle
|
<LayoutTransitionToggle
|
||||||
title={language.t("settings.general.row.newInterface.title")}
|
title={language.t("settings.general.row.newInterface.title")}
|
||||||
@@ -248,59 +320,18 @@ export const SettingsGeneralV2: Component<{
|
|||||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<SettingsRowV2
|
<LanguageSetting />
|
||||||
title={language.t("settings.general.row.language.title")}
|
|
||||||
description={language.t("settings.general.row.language.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-language"
|
|
||||||
options={languageOptions()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
current={languageOptions().find((o) => o.value === language.locale())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && language.setLocale(option.value)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-auto-accept-permissions">
|
|
||||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<ShellSetting controller={shell} />
|
||||||
title={language.t("settings.general.row.shell.title")}
|
|
||||||
description={language.t("settings.general.row.shell.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-shell"
|
|
||||||
options={shellOptions()}
|
|
||||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
if (option.value === currentShell()) return
|
|
||||||
serverSync().updateConfig({ shell: option.value })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
@@ -411,124 +442,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
|
|
||||||
<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={colorSchemeOptions()}
|
|
||||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
|
||||||
{language.t("common.learnMore")}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={themeOptions()}
|
|
||||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.name}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
theme.setTheme(option.id)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.uiFont.title")}
|
|
||||||
description={language.t("settings.general.row.uiFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action="settings-ui-font"
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={sans()}
|
|
||||||
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
|
|
||||||
placeholder={sansDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t("settings.general.row.uiFont.title")}
|
|
||||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.font.title")}
|
|
||||||
description={language.t("settings.general.row.font.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action="settings-code-font"
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={mono()}
|
|
||||||
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
|
|
||||||
placeholder={monoDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t("settings.general.row.font.title")}
|
|
||||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.terminalFont.title")}
|
|
||||||
description={language.t("settings.general.row.terminalFont.description")}
|
|
||||||
>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action="settings-terminal-font"
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={terminal()}
|
|
||||||
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
|
|
||||||
placeholder={terminalDefault}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t("settings.general.row.terminalFont.title")}
|
|
||||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
const NotificationsSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||||
@@ -573,68 +486,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const SoundsSection = () => (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.sounds.agent.title")}
|
|
||||||
description={language.t("settings.general.sounds.agent.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-sounds-agent"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.agentEnabled(),
|
|
||||||
() => settings.sounds.agent(),
|
|
||||||
(value) => settings.sounds.setAgentEnabled(value),
|
|
||||||
(id) => settings.sounds.setAgent(id),
|
|
||||||
)}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.sounds.permissions.title")}
|
|
||||||
description={language.t("settings.general.sounds.permissions.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-sounds-permissions"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.permissionsEnabled(),
|
|
||||||
() => settings.sounds.permissions(),
|
|
||||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
|
||||||
(id) => settings.sounds.setPermissions(id),
|
|
||||||
)}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.sounds.errors.title")}
|
|
||||||
description={language.t("settings.general.sounds.errors.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-sounds-errors"
|
|
||||||
{...soundSelectProps(
|
|
||||||
() => settings.sounds.errorsEnabled(),
|
|
||||||
() => settings.sounds.errors(),
|
|
||||||
(value) => settings.sounds.setErrorsEnabled(value),
|
|
||||||
(id) => settings.sounds.setErrors(id),
|
|
||||||
)}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const UpdatesSection = () => (
|
const UpdatesSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||||
@@ -656,7 +507,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
title={language.t("settings.updates.row.check.title")}
|
title={language.t("settings.updates.row.check.title")}
|
||||||
description={language.t("settings.updates.row.check.description")}
|
description={language.t("settings.updates.row.check.description")}
|
||||||
>
|
>
|
||||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
|
||||||
{language.t(updater.action().label)}
|
{language.t(updater.action().label)}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
@@ -701,11 +552,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection controller={appearance} />
|
||||||
|
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
|
|
||||||
<SoundsSection />
|
<SoundsSection controller={sounds} />
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
|
|||||||
export const SettingsModelsV2: Component = () => {
|
export const SettingsModelsV2: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
const [store, setStore] = persisted(
|
||||||
|
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||||
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
|
)
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
const list = useFilteredList<ModelItem>({
|
||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
@@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={list.grouped.latest}>
|
<For each={list.grouped.latest}>
|
||||||
{(group) => (
|
{(group) => {
|
||||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
const searching = () => list.filter().length > 0
|
||||||
<div class="settings-v2-models-group-header">
|
const expanded = () => searching() || !store.collapsed[group.category]
|
||||||
<ProviderIcon
|
|
||||||
id={group.category}
|
return (
|
||||||
width={PROVIDER_ICON_SIZE}
|
<div
|
||||||
height={PROVIDER_ICON_SIZE}
|
class="settings-v2-section"
|
||||||
class="settings-v2-models-provider-icon shrink-0"
|
data-component="settings-models-provider"
|
||||||
/>
|
data-expanded={expanded() ? "" : undefined}
|
||||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
>
|
||||||
|
<h3 class="settings-v2-models-group-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-v2-models-group-trigger"
|
||||||
|
aria-expanded={expanded()}
|
||||||
|
disabled={searching()}
|
||||||
|
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||||
|
>
|
||||||
|
<span class="settings-v2-models-group-chevron">
|
||||||
|
<Show
|
||||||
|
when={expanded()}
|
||||||
|
fallback={
|
||||||
|
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span class="settings-v2-models-group-label">
|
||||||
|
<ProviderIcon
|
||||||
|
id={group.category}
|
||||||
|
width={PROVIDER_ICON_SIZE}
|
||||||
|
height={PROVIDER_ICON_SIZE}
|
||||||
|
class="settings-v2-models-provider-icon shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => {
|
||||||
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={item.name} description="">
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
checked={models.visible(key)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
models.setVisibility(key, checked)
|
||||||
|
}}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<SettingsListV2>
|
)
|
||||||
<For each={group.items}>
|
}}
|
||||||
{(item) => {
|
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={item.name} description="">
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
checked={models.visible(key)}
|
|
||||||
onChange={(checked) => {
|
|
||||||
models.setVisibility(key, checked)
|
|
||||||
}}
|
|
||||||
hideLabel
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
@@ -29,17 +29,21 @@ const PROVIDER_NOTES = [
|
|||||||
|
|
||||||
const PROVIDER_ICON_SIZE = 16
|
const PROVIDER_ICON_SIZE = 16
|
||||||
|
|
||||||
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
|
export const SettingsProvidersV2: Component<{
|
||||||
|
directory: Accessor<string | undefined>
|
||||||
|
onBack?: () => void
|
||||||
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
@@ -77,7 +81,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
const canDisconnect = (item: ProviderItem) =>
|
||||||
|
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
@@ -90,6 +95,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
@@ -218,33 +224,37 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="settings-v2-provider-lead">
|
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||||
<ProviderIcon
|
<div class="settings-v2-provider-lead">
|
||||||
id="synthetic"
|
<ProviderIcon
|
||||||
width={PROVIDER_ICON_SIZE}
|
id="synthetic"
|
||||||
height={PROVIDER_ICON_SIZE}
|
width={PROVIDER_ICON_SIZE}
|
||||||
class="settings-v2-provider-icon shrink-0"
|
height={PROVIDER_ICON_SIZE}
|
||||||
/>
|
class="settings-v2-provider-icon shrink-0"
|
||||||
<div class="settings-v2-provider-copy">
|
/>
|
||||||
<div class="settings-v2-provider-main">
|
<div class="settings-v2-provider-copy">
|
||||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
<div class="settings-v2-provider-main">
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
||||||
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
|
</div>
|
||||||
|
<p class="settings-v2-provider-description">
|
||||||
|
{language.t("settings.providers.custom.description")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonV2
|
||||||
|
size="normal"
|
||||||
|
variant="neutral"
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
<ButtonV2
|
</Show>
|
||||||
size="normal"
|
|
||||||
variant="neutral"
|
|
||||||
icon="plus"
|
|
||||||
onClick={() => {
|
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("common.connect")}
|
|
||||||
</ButtonV2>
|
|
||||||
</div>
|
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
|
|
||||||
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
||||||
|
|||||||
@@ -133,9 +133,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="settings-v2-row-description"] {
|
[data-slot="settings-v2-row-description"] {
|
||||||
|
margin-block: -3.5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
line-height: 1;
|
line-height: 20px;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--v2-text-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,10 +271,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-provider-description {
|
.settings-v2-provider-description {
|
||||||
margin: 0;
|
margin-block: -3.5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
line-height: 1;
|
line-height: 20px;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--v2-text-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,14 +373,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models {
|
.settings-v2-models {
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models .settings-v2-section {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models .settings-v2-section[data-expanded] {
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models-group-header {
|
.settings-v2-models-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--v2-border-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.settings-v2-models-group-trigger:not(:disabled):hover {
|
||||||
|
background-color: var(--v2-background-bg-layer-02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-chevron {
|
||||||
|
display: flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-models-group-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title {
|
.settings-v2-models .settings-v2-section-title {
|
||||||
@@ -393,6 +444,10 @@
|
|||||||
color: var(--v2-icon-icon-base);
|
color: var(--v2-icon-icon-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-models [data-component="settings-v2-list"] {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
|
import { useServerProtocol } from "@/context/server-sdk"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -257,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -276,7 +278,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
dialogDead = true
|
dialogDead = true
|
||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
const sortedServers = createMemo(() => {
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? global.servers.list()
|
||||||
|
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||||
|
return listServersByHealth(list, server.key, global.servers.health)
|
||||||
|
})
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
@@ -303,7 +310,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||||
{!settings.general.newLayoutDesigns() && (
|
{!settings.general.newLayoutDesigns() && (
|
||||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||||
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
|
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||||
{language.t("status.popover.tab.servers")}
|
{language.t("status.popover.tab.servers")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
)}
|
)}
|
||||||
@@ -315,10 +322,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
{language.t("status.popover.tab.lsp")}
|
{language.t("status.popover.tab.lsp")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
<Show when={protocol() === "v1"}>
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
{language.t("status.popover.tab.plugins")}
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
</Tabs.Trigger>
|
{language.t("status.popover.tab.plugins")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{!settings.general.newLayoutDesigns() && (
|
{!settings.general.newLayoutDesigns() && (
|
||||||
@@ -478,25 +487,27 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="plugins">
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<Tabs.Content value="plugins">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<Show
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
when={plugins().length > 0}
|
<Show
|
||||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
when={plugins().length > 0}
|
||||||
>
|
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||||
<For each={plugins()}>
|
>
|
||||||
{(plugin) => (
|
<For each={plugins()}>
|
||||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
{(plugin) => (
|
||||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||||
</div>
|
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||||
)}
|
</div>
|
||||||
</For>
|
)}
|
||||||
</Show>
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs.Content>
|
||||||
</Tabs.Content>
|
</Show>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
import {
|
||||||
|
hasNonBlockingServiceIssue,
|
||||||
|
hasServiceNeedingAttention,
|
||||||
|
serverStatusDotClass,
|
||||||
|
} from "./status-popover-indicator"
|
||||||
|
|
||||||
describe("serverStatusDotClass", () => {
|
describe("serverStatusDotClass", () => {
|
||||||
test("uses the success token while the server and services are healthy", () => {
|
test("uses the success token while the server and services are healthy", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses the session attention token when a service needs attention", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
|
||||||
|
"bg-v2-background-bg-accent",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("uses the warning token for non-blocking issues while the server is online", () => {
|
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||||
})
|
})
|
||||||
@@ -26,7 +36,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("detects LSP failures that do not block chatting", () => {
|
test("detects LSP failures that do not block chatting", () => {
|
||||||
@@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
|
|||||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("hasServiceNeedingAttention", () => {
|
||||||
|
test("detects MCP states that need user attention", () => {
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores states that do not need user attention", () => {
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
|
||||||
|
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||||
|
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||||
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
mcp: Array<McpStatus["status"]>
|
mcp: Array<McpServer["status"]["status"]>
|
||||||
lsp: Array<LspStatus["status"]>
|
lsp: Array<LspStatus["status"]>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||||
input.lsp.some((status) => status === "error")
|
input.lsp.some((status) => status === "error")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
export function serverStatusDotClass(input: {
|
||||||
|
ready: boolean
|
||||||
|
serverHealth: boolean | undefined
|
||||||
|
attention?: boolean
|
||||||
|
issue: boolean
|
||||||
|
}) {
|
||||||
if (input.serverHealth === false) return "bg-icon-critical-base"
|
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||||
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||||
|
if (input.attention) return "bg-v2-background-bg-accent"
|
||||||
if (input.issue) return "bg-icon-warning-base"
|
if (input.issue) return "bg-icon-warning-base"
|
||||||
if (input.serverHealth === true) return "bg-icon-success-base"
|
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||||
return "bg-border-weak-base"
|
return "bg-border-weak-base"
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
import {
|
||||||
|
hasNonBlockingServiceIssue,
|
||||||
|
hasServiceNeedingAttention,
|
||||||
|
serverStatusDotClass,
|
||||||
|
} from "./status-popover-indicator"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||||
@@ -22,6 +26,11 @@ export function StatusPopover() {
|
|||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
|
const attention = createMemo(() =>
|
||||||
|
hasServiceNeedingAttention({
|
||||||
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
@@ -49,6 +58,7 @@ export function StatusPopover() {
|
|||||||
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: attention(),
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
})}`}
|
})}`}
|
||||||
/>
|
/>
|
||||||
@@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
|
|||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.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 ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
|
const attention = createMemo(() =>
|
||||||
|
hasServiceNeedingAttention({
|
||||||
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
@@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
|
|||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: attention(),
|
||||||
issue: issue(),
|
issue: issue(),
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
@@ -118,6 +134,7 @@ function ServerStatusPopover() {
|
|||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
attention: false,
|
||||||
issue: false,
|
issue: false,
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
@@ -135,6 +152,7 @@ type StatusPopoverState = {
|
|||||||
shown: boolean
|
shown: boolean
|
||||||
ready: boolean
|
ready: boolean
|
||||||
serverHealth: boolean | undefined
|
serverHealth: boolean | undefined
|
||||||
|
attention: boolean
|
||||||
issue: boolean
|
issue: boolean
|
||||||
label: string
|
label: string
|
||||||
onOpenChange: (value: boolean) => void
|
onOpenChange: (value: boolean) => void
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||||
const connection = useServerSDK()().server
|
const connection = useServerSDK()().server
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const client = sdk().client
|
|
||||||
const url = sdk().url
|
const url = sdk().url
|
||||||
const auth = connection.http
|
const auth = connection.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
@@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushSize = (cols: number, rows: number) => {
|
const pushSize = async (cols: number, rows: number) => {
|
||||||
return client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.update({
|
return sdk()
|
||||||
|
.client.pty.update({
|
||||||
|
ptyID: id,
|
||||||
|
size: { cols, rows },
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
debugTerminal("failed to sync terminal size", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sdk()
|
||||||
|
.api.pty.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
|
location: { directory },
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -292,7 +302,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const scheduleSize = (cols: number, rows: number) => {
|
const scheduleSize = (cols: number, rows: number) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (lastSize?.cols === cols && lastSize?.rows === rows) return
|
if (lastSize?.cols === cols && lastSize?.rows === rows) {
|
||||||
|
pendingSize = undefined
|
||||||
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
|
sizeTimer = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
pendingSize = { cols, rows }
|
pendingSize = { cols, rows }
|
||||||
|
|
||||||
@@ -317,8 +332,10 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const colors = terminalColors()
|
const colors = terminalColors()
|
||||||
|
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||||
if (!term) return
|
if (!term) return
|
||||||
setOptionIfSupported(term, "theme", colors)
|
setOptionIfSupported(term, "theme", colors)
|
||||||
|
setOptionIfSupported(term, "colorScheme", mode)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -365,7 +382,11 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
platform.openLink(text)
|
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
|
||||||
|
platform.openLocalFile(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platform.openExternal(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -396,6 +417,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
_ghostty = g
|
_ghostty = g
|
||||||
term = t
|
term = t
|
||||||
|
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
|
||||||
output = terminalWriter((data, done) =>
|
output = terminalWriter((data, done) =>
|
||||||
t.write(data, () => {
|
t.write(data, () => {
|
||||||
done?.()
|
done?.()
|
||||||
@@ -514,34 +536,54 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
local.onConnectError?.(err)
|
local.onConnectError?.(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const gone = () =>
|
const gone = async () => {
|
||||||
client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.get({ ptyID: id }, { throwOnError: false })
|
return sdk()
|
||||||
.then((result) => result.response.status === 404)
|
.client.pty.get({ ptyID: id }, { throwOnError: false })
|
||||||
|
.then((result) => result.response.status === 404)
|
||||||
|
.catch((err) => {
|
||||||
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sdk()
|
||||||
|
.api.pty.get({ ptyID: id, location: { directory } })
|
||||||
|
.then((result) => result.data.status === "exited")
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
||||||
debugTerminal("failed to inspect terminal session", err)
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
const result = await client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.connectToken(
|
const result = await sdk()
|
||||||
{ ptyID: id, directory },
|
.client.pty.connectToken(
|
||||||
{
|
{ ptyID: id, directory },
|
||||||
throwOnError: false,
|
{
|
||||||
headers: { "x-opencode-ticket": "1" },
|
throwOnError: false,
|
||||||
},
|
headers: { "x-opencode-ticket": "1" },
|
||||||
)
|
},
|
||||||
.catch((err: unknown) => {
|
)
|
||||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
.catch((err: unknown) => {
|
||||||
throw err
|
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||||
})
|
throw err
|
||||||
if (!result) return
|
})
|
||||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
if (!result) return
|
||||||
if (result.response.status === 404 || result.response.status === 405) return
|
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||||
if (result.response.status === 403)
|
if (result.response.status === 404 || result.response.status === 405) return
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
if (result.response.status === 403)
|
||||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||||
|
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||||
|
}
|
||||||
|
// return sdk()
|
||||||
|
// .api.pty.connectToken({
|
||||||
|
// ptyID: id,
|
||||||
|
// location: { directory },
|
||||||
|
// "x-opencode-ticket": "1",
|
||||||
|
// })
|
||||||
|
// .then((result) => result.data.ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = (err: unknown) => {
|
const retry = (err: unknown) => {
|
||||||
@@ -571,11 +613,14 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
fail(err)
|
fail(err)
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
const protocol = await sdk().protocol
|
||||||
|
// if (protocol === "v2" && !ticket) return
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(
|
||||||
terminalWebSocketURL({
|
terminalWebSocketURL({
|
||||||
|
protocol,
|
||||||
url,
|
url,
|
||||||
id,
|
id,
|
||||||
directory,
|
directory,
|
||||||
@@ -595,6 +640,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
tries = 0
|
tries = 0
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
|
if (t.getMode(2031)) t.write("\x1b[?996n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
|||||||
if (typeof ref === "function") ref(element)
|
if (typeof ref === "function") ref(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
|
||||||
return !dragging && !editing && !committing
|
return !dragging && !editing && !pending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,53 +9,86 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab] {
|
||||||
|
--tab-base: var(--v2-background-bg-deep);
|
||||||
|
--tab-overlay: transparent;
|
||||||
|
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
|
||||||
|
[data-dragging="true"]
|
||||||
|
):not([data-editing="true"]) {
|
||||||
|
--tab-base: var(--v2-background-bg-layer-02);
|
||||||
|
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
|
||||||
|
--tab-base: var(--v2-background-bg-layer-02);
|
||||||
|
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||||
|
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||||
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-list] {
|
[data-titlebar-tab-list] {
|
||||||
gap: 13.5px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot] {
|
[data-titlebar-tab-slot] {
|
||||||
|
--tab-separator: var(--v2-background-bg-layer-03);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
|
||||||
|
--tab-separator: var(--v2-background-bg-layer-02);
|
||||||
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: -6.75px;
|
left: -3.75px;
|
||||||
width: 1.5px;
|
width: 1.5px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: var(--v2-background-bg-layer-02);
|
background: var(--tab-separator);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
|
||||||
content: "";
|
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
height: 28px;
|
|
||||||
width: 16px;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(to right, transparent, var(--tab-bg));
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||||
display: block;
|
--tab-title-fade-offset: 4px;
|
||||||
right: 0;
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
black 0,
|
||||||
|
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||||
|
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
black 0,
|
||||||
|
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||||
|
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
|
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
||||||
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
|
[data-slot="tab-link"] {
|
||||||
[data-slot="tab-close"]::before {
|
--tab-title-fade-offset: 24px;
|
||||||
right: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user