Compare commits
277 commits
reasoning-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
472 changed files with 31711 additions and 8693 deletions
7
.github/workflows/publish.yml
vendored
7
.github/workflows/publish.yml
vendored
|
|
@ -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.
|
||||||
|
|
|
||||||
136
bun.lock
136
bun.lock
|
|
@ -29,7 +29,7 @@
|
||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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.2",
|
"version": "1.18.11",
|
||||||
"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=="],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,13 +43,30 @@ 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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
postPatch =
|
||||||
|
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||||
|
''
|
||||||
|
substituteInPlace packages/script/src/index.ts \
|
||||||
|
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||||
|
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||||
|
''
|
||||||
# https://github.com/electron/electron/issues/31121
|
# https://github.com/electron/electron/issues/31121
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
postPatch = lib.optionalString stdenv.isLinux ''
|
+ lib.optionalString stdenv.isLinux ''
|
||||||
BASE_PATH=packages/desktop
|
BASE_PATH=packages/desktop
|
||||||
FILES=(src/main/windows.ts)
|
FILES=(src/main/windows.ts)
|
||||||
for file in "''${FILES[@]}"; do
|
for file in "''${FILES[@]}"; do
|
||||||
|
|
@ -76,8 +98,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
''
|
''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
|
|
@ -88,6 +109,18 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
+ 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 \
|
||||||
|
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/icons/64x64.png \
|
||||||
|
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/icons/128x128.png \
|
||||||
|
"$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"
|
||||||
|
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 \
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
--inherit-argv0 \
|
--inherit-argv0 \
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
|
|
|
||||||
|
|
@ -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="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
220
packages/app/V1_API_MIGRATION.md
Normal file
220
packages/app/V1_API_MIGRATION.md
Normal file
|
|
@ -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, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
packages/app/e2e/regression/open-file-expand-folder.spec.ts
Normal file
132
packages/app/e2e/regression/open-file-expand-folder.spec.ts
Normal file
|
|
@ -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" }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
packages/app/e2e/user-story/model-selection-flow.spec.ts
Normal file
97
packages/app/e2e/user-story/model-selection-flow.spec.ts
Normal file
|
|
@ -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.2",
|
"version": "1.18.11",
|
||||||
"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)
|
|
||||||
.map((session) => ({
|
|
||||||
id: session.id,
|
|
||||||
title: session.title ?? props.untitled(),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: session.time?.archived,
|
|
||||||
updated: session.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
.catch(() => [] as SessionEntryInput[])
|
})
|
||||||
|
if (current.signal.aborted) return []
|
||||||
|
const opened = props.opened()
|
||||||
|
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||||
|
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||||
|
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||||
|
return props
|
||||||
|
.load(search, current.signal)
|
||||||
|
.then((result) =>
|
||||||
|
result.data
|
||||||
|
.map(normalizeSessionInfo)
|
||||||
|
.filter((session) => !session.time.archived)
|
||||||
|
.map((session) => {
|
||||||
|
const project =
|
||||||
|
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||||
|
return {
|
||||||
|
id: `session:${props.server}:${session.id}`,
|
||||||
|
type: "session" as const,
|
||||||
|
title: session.title || props.untitled(),
|
||||||
|
description: project ? displayName(project) : getFilename(session.directory),
|
||||||
|
category: props.category(),
|
||||||
|
directory: session.directory,
|
||||||
|
sessionID: session.id,
|
||||||
|
server: props.server,
|
||||||
|
project,
|
||||||
|
updated: session.time.updated,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then((results) => {
|
|
||||||
if (state.token !== current) return [] as CommandPaletteEntry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((item) => createSessionEntry(item, props.category()))
|
|
||||||
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,11 +66,18 @@ 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,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex min-w-0": true,
|
||||||
|
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||||
|
"flex-col items-center": !props.inline,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|
@ -90,6 +98,7 @@ function Cell(props: {
|
||||||
{props.value}
|
{props.value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (props.inline) {
|
if (props.inline) {
|
||||||
|
|
@ -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,14 +59,48 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Content() {
|
||||||
return (
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={controller.selected() === CUSTOM_ID}>
|
||||||
|
<CustomProviderForm autofocus={!newLayout()} />
|
||||||
|
</Match>
|
||||||
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||||
|
{(provider) => (
|
||||||
|
<ProviderConnection
|
||||||
|
provider={provider()}
|
||||||
|
directory={props.directory}
|
||||||
|
onBack={reset}
|
||||||
|
setBack={(handler) => (back.current = handler)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<ProviderPicker
|
||||||
|
directory={props.directory}
|
||||||
|
onSelect={select}
|
||||||
|
onPrepare={newLayout() ? holdFocus : undefined}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={newLayout()}
|
||||||
|
fallback={
|
||||||
<Dialog
|
<Dialog
|
||||||
class="h-full"
|
class="h-full"
|
||||||
transition
|
transition
|
||||||
|
|
@ -73,30 +116,48 @@ export const DialogConnectProvider: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Content />
|
||||||
<Match when={controller.selected() === CUSTOM_ID}>
|
|
||||||
<CustomProviderForm />
|
|
||||||
</Match>
|
|
||||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
|
||||||
{(provider) => (
|
|
||||||
<ProviderConnection
|
|
||||||
provider={provider()}
|
|
||||||
directory={props.directory}
|
|
||||||
onBack={reset}
|
|
||||||
setBack={(handler) => (back.current = handler)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Dialog>
|
</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,38 +66,46 @@ 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">
|
|
||||||
<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")}
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<For each={model.list()}>
|
<For each={freeModels()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
openDelay={0}
|
openDelay={0}
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
|
value={
|
||||||
|
<ModelTooltip
|
||||||
|
model={{ ...item, name: displayModelName(item.name) }}
|
||||||
|
latest={item.latest}
|
||||||
|
free={isFree(item)}
|
||||||
|
v2
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={() => selectModel(item)}
|
onClick={() => selectModel(item)}
|
||||||
>
|
>
|
||||||
<span class="min-w-0 truncate">{item.name}</span>
|
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||||
<Show when={isFree(item)}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
|
||||||
<Show when={item.latest}>
|
<Show when={item.latest}>
|
||||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -106,67 +118,58 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
<div class="flex w-full flex-col">
|
||||||
<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)]">
|
<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 flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
||||||
<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]">
|
<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")}
|
{language.t("dialog.model.unpaid.addMore.title")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-col">
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
<For
|
<For
|
||||||
each={[...providers.popular()].sort((a, b) => {
|
each={[...providers.popular()]
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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)}
|
onClick={() => openProviders(provider.id)}
|
||||||
>
|
>
|
||||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
<span class="min-w-0 truncate">{provider.name}</span>
|
<span class="flex min-w-0 flex-col">
|
||||||
<Show when={provider.id === "opencode"}>
|
<span class="truncate">{provider.name}</span>
|
||||||
<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]">
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
{language.t("dialog.provider.opencode.tagline")}
|
<span class="truncate font-[440] text-v2-text-text-muted">
|
||||||
</span>
|
{language.t(
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
provider.id === "opencode"
|
||||||
</Show>
|
? "dialog.provider.opencode.tagline"
|
||||||
<Show when={provider.id === "opencode-go"}>
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
<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>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</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="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()}
|
onClick={() => openProviders()}
|
||||||
>
|
>
|
||||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||||
<Icon name="dot-grid" size="small" />
|
|
||||||
</span>
|
|
||||||
<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]">
|
|
||||||
{language.t("dialog.provider.viewAll")}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
|
||||||
</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 {
|
||||||
|
models: (search: string) => {
|
||||||
|
const query = search.trim()
|
||||||
|
const filtered = query
|
||||||
|
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||||
|
: allModels()
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
},
|
||||||
const groups = createMemo(() => {
|
groups: (models: ModelItem[]) => {
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
for (const item of models()) {
|
for (const item of models) {
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
}
|
}
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
})
|
},
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
current: () => {
|
||||||
const current = () => {
|
|
||||||
const value = model.current()
|
const value = model.current()
|
||||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
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
|
||||||
|
const project = await serverCtx()
|
||||||
|
.sdk.client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
commands: { start },
|
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
|
||||||
|
|
|
||||||
21
packages/app/src/components/external-link.tsx
Normal file
21
packages/app/src/components/external-link.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ 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">
|
||||||
|
|
@ -70,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
586
packages/app/src/components/prompt-input-v2.tsx
Normal file
586
packages/app/src/components/prompt-input-v2.tsx
Normal file
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import {
|
||||||
Match,
|
Match,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
|
||||||
import type { useLocal } from "@/context/local"
|
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
ContentPart,
|
ContentPart,
|
||||||
|
|
@ -49,7 +47,6 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
@ -60,121 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
prependHistoryEntry,
|
|
||||||
type PromptHistoryComment,
|
type PromptHistoryComment,
|
||||||
type PromptHistoryEntry,
|
type PromptHistoryEntry,
|
||||||
type PromptHistoryStoredEntry,
|
|
||||||
promptLength,
|
promptLength,
|
||||||
} from "./prompt-input/history"
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
import {
|
||||||
|
createPersistedPromptInputHistory,
|
||||||
|
createPromptInputHistory,
|
||||||
|
type PromptInputHistory,
|
||||||
|
} from "./prompt-input/history-store"
|
||||||
|
import {
|
||||||
|
type PromptInputControls,
|
||||||
|
type PromptInputProps,
|
||||||
|
type PromptInputState,
|
||||||
|
type PromptInputSubmission,
|
||||||
|
} from "./prompt-input/contracts"
|
||||||
|
import { createPromptSubmit } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
export { createPromptInputHistory }
|
||||||
|
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||||
export type PromptInputHistory = {
|
|
||||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
|
||||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newLayoutDesigns: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptInputHistory(): PromptInputHistory {
|
|
||||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
|
||||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
|
||||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPersistedPromptInputHistory() {
|
|
||||||
const [normal, setNormal] = persisted(
|
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
|
||||||
)
|
|
||||||
const [shell, setShell] = persisted(
|
|
||||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
|
||||||
)
|
|
||||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromptInputProps {
|
|
||||||
class?: string
|
|
||||||
variant?: "dock" | "new-session"
|
|
||||||
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
|
|
||||||
toolbar?: JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXAMPLES = [
|
const EXAMPLES = [
|
||||||
"prompt.example.1",
|
"prompt.example.1",
|
||||||
|
|
@ -681,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
client: resource.client,
|
client: resource.server,
|
||||||
display: resource.name,
|
display: resource.name,
|
||||||
description: resource.description,
|
description: resource.description,
|
||||||
mime: resource.mimeType,
|
mime: resource.mimeType,
|
||||||
|
|
@ -799,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
type: "custom" as const,
|
type: "custom" as const,
|
||||||
source: cmd.source,
|
// source: cmd.source,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...custom, ...builtin]
|
return [...custom, ...builtin]
|
||||||
|
|
@ -1191,28 +1101,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCommands = () => {
|
|
||||||
const populated = prompt.dirty() || commentCount() > 0
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!populated) {
|
|
||||||
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
|
|
||||||
slashOnInput("")
|
|
||||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slashOnInput("")
|
|
||||||
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openContext = () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
|
|
||||||
atOnInput("")
|
|
||||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||||
}
|
}
|
||||||
|
|
@ -1539,50 +1427,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
(p) => p,
|
(p) => p,
|
||||||
)
|
)
|
||||||
|
|
||||||
const designPlaceholder = () => {
|
|
||||||
if (store.mode === "shell") return placeholder()
|
|
||||||
return "Ask anything, / for commands, @ for context..."
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
|
||||||
loading: providersLoading(),
|
|
||||||
shouldAnimate: providersShouldFadeIn(),
|
|
||||||
paid: props.controls.model.paid,
|
|
||||||
title: language.t("command.model.choose"),
|
|
||||||
keybind: command.keybindParts("model.choose"),
|
|
||||||
model: props.controls.model.selection,
|
|
||||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
|
||||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
|
||||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
|
||||||
style: control(),
|
|
||||||
onClose: restoreFocus,
|
|
||||||
onUnpaidClick: () => {
|
|
||||||
if (props.controls.newLayoutDesigns) {
|
|
||||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const newSession = () => props.variant === "new-session"
|
|
||||||
const bindEditorRef = (el: HTMLDivElement) => {
|
const bindEditorRef = (el: HTMLDivElement) => {
|
||||||
editorRef = el
|
editorRef = el
|
||||||
restoreEndOnFocus = true
|
restoreEndOnFocus = true
|
||||||
props.ref?.(el)
|
props.ref?.(el)
|
||||||
}
|
}
|
||||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
|
||||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
|
||||||
title: language.t("command.agent.cycle"),
|
|
||||||
keybind: command.keybindParts("agent.cycle"),
|
|
||||||
options: props.controls.agents.options,
|
|
||||||
current: props.controls.agents.current,
|
|
||||||
style: control(),
|
|
||||||
onSelect: (value) => {
|
|
||||||
props.controls.agents.select(value)
|
|
||||||
restoreFocus()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full flex flex-col gap-0">
|
<div class="relative size-full flex flex-col gap-0">
|
||||||
{(promptReady(), null)}
|
{(promptReady(), null)}
|
||||||
|
|
@ -1607,254 +1456,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
onSlashMenuKeyDown={handleSlashMenuKeyDown}
|
onSlashMenuKeyDown={handleSlashMenuKeyDown}
|
||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
commandKeybindParts={command.keybindParts}
|
commandKeybindParts={command.keybindParts}
|
||||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
newLayoutDesigns={false}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
|
||||||
<Match when={props.controls.newLayoutDesigns}>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<DockShellForm
|
|
||||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
classList={{
|
|
||||||
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
|
|
||||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
|
||||||
[props.class ?? ""]: !!props.class,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PromptDragOverlay
|
|
||||||
type={store.draggingType}
|
|
||||||
label={language.t(
|
|
||||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<PromptContextItems
|
|
||||||
items={contextItems().filter((item) => !isCommentItem(item))}
|
|
||||||
active={(item) => {
|
|
||||||
const active = comments.active()
|
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
|
||||||
}}
|
|
||||||
openComment={openComment}
|
|
||||||
remove={(item) => {
|
|
||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
|
||||||
prompt.context.remove(item.key)
|
|
||||||
}}
|
|
||||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
|
||||||
/>
|
|
||||||
<PromptImageAttachments
|
|
||||||
attachments={imageAttachments()}
|
|
||||||
onOpen={(attachment) =>
|
|
||||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
|
||||||
}
|
|
||||||
onRemove={removeAttachment}
|
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
|
||||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
|
||||||
comments={contextItems().filter(isCommentItem)}
|
|
||||||
commentActive={(item) => {
|
|
||||||
const active = comments.active()
|
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
|
||||||
}}
|
|
||||||
onOpenComment={openComment}
|
|
||||||
onRemoveComment={(item) => {
|
|
||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
|
||||||
prompt.context.remove(item.key)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="relative min-h-[52px]"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
const target = e.target
|
|
||||||
if (!(target instanceof HTMLElement)) return
|
|
||||||
if (target.closest('[data-action^="prompt-"]')) return
|
|
||||||
editorRef?.focus()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
|
||||||
<div
|
|
||||||
data-component="prompt-input"
|
|
||||||
ref={bindEditorRef}
|
|
||||||
role="textbox"
|
|
||||||
aria-multiline="true"
|
|
||||||
aria-label={designPlaceholder()}
|
|
||||||
contenteditable="true"
|
|
||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
|
||||||
spellcheck={store.mode === "normal"}
|
|
||||||
inputMode="text"
|
|
||||||
// @ts-expect-error
|
|
||||||
autocomplete="off"
|
|
||||||
onInput={handleInput}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
onCompositionStart={handleCompositionStart}
|
|
||||||
onCompositionEnd={handleCompositionEnd}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
classList={{
|
|
||||||
"select-text": true,
|
|
||||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
|
|
||||||
"[&_[data-type=file]]:text-syntax-property": true,
|
|
||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
|
||||||
"font-mono!": store.mode === "shell",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
|
|
||||||
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
|
|
||||||
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
|
|
||||||
>
|
|
||||||
{designPlaceholder()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-11 items-center px-2">
|
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
|
||||||
{fileAttachmentInput()}
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{language.t("prompt.menu.addImagesAndFiles")}
|
|
||||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
|
||||||
<MenuV2.Trigger
|
|
||||||
as={IconButtonV2}
|
|
||||||
data-action="prompt-attach"
|
|
||||||
type="button"
|
|
||||||
icon={<IconV2 name="plus" />}
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="large"
|
|
||||||
style={buttons()}
|
|
||||||
disabled={store.mode !== "normal"}
|
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
|
||||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
|
||||||
/>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.Content style={{ "min-width": "180px" }}>
|
|
||||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
|
||||||
{language.t("prompt.menu.imagesAndFiles")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
<MenuV2.Separator />
|
|
||||||
<MenuV2.Item onSelect={openCommands} shortcut="/">
|
|
||||||
{language.t("prompt.menu.commands")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
<MenuV2.Item onSelect={openContext} shortcut="@">
|
|
||||||
{language.t("prompt.menu.context")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
|
|
||||||
{language.t("prompt.menu.shellCommand")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
</TooltipV2>
|
|
||||||
<Show when={showAgentControl()}>
|
|
||||||
<ComposerAgentControl state={agentControlState()} />
|
|
||||||
</Show>
|
|
||||||
{props.toolbar}
|
|
||||||
<ComposerModelControl state={modelControlState()} />
|
|
||||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
|
||||||
<div
|
|
||||||
data-component="prompt-variant-control"
|
|
||||||
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
|
||||||
classList={{
|
|
||||||
"animate-in fade-in": providersShouldFadeIn(),
|
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
|
||||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{language.t("command.model.variant.cycle")}
|
|
||||||
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuV2
|
|
||||||
gutter={6}
|
|
||||||
modal={false}
|
|
||||||
placement="top-start"
|
|
||||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
|
||||||
>
|
|
||||||
<MenuV2.Trigger
|
|
||||||
as={ButtonV2}
|
|
||||||
data-action="prompt-model-variant"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="normal"
|
|
||||||
class="max-w-[160px] justify-start capitalize"
|
|
||||||
style={control()}
|
|
||||||
>
|
|
||||||
<span class="truncate leading-5">
|
|
||||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
|
||||||
</span>
|
|
||||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.Content>
|
|
||||||
<MenuV2.RadioGroup
|
|
||||||
value={props.controls.model.selection.variant.current() ?? "default"}
|
|
||||||
onChange={(value) => {
|
|
||||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
|
||||||
restoreFocus()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{variants().map((value) => (
|
|
||||||
<MenuV2.RadioItem value={value} class="capitalize">
|
|
||||||
{value === "default" ? language.t("common.default") : value}
|
|
||||||
</MenuV2.RadioItem>
|
|
||||||
))}
|
|
||||||
</MenuV2.RadioGroup>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
</TooltipV2>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
|
||||||
<IconButton
|
|
||||||
data-action="prompt-submit"
|
|
||||||
type="submit"
|
|
||||||
disabled={!working() && blank()}
|
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
|
||||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
|
||||||
variant="primary"
|
|
||||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
"background-image":
|
|
||||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
|
||||||
}}
|
|
||||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
|
||||||
/>
|
|
||||||
</TooltipV2>
|
|
||||||
</div>
|
|
||||||
</DockShellForm>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when>
|
|
||||||
<DockShellForm
|
<DockShellForm
|
||||||
|
data-dock-border-underlay="legacy"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
"group/prompt-input": true,
|
"group/prompt-input": true,
|
||||||
"focus-within:shadow-xs-border": true,
|
|
||||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PromptDragOverlay
|
<PromptDragOverlay
|
||||||
type={store.draggingType}
|
type={store.draggingType}
|
||||||
label={language.t(
|
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={contextItems()}
|
items={contextItems()}
|
||||||
|
|
@ -1867,17 +1483,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
prompt.context.remove(item.key)
|
prompt.context.remove(item.key)
|
||||||
}}
|
}}
|
||||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
newLayoutDesigns={false}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<PromptImageAttachments
|
<PromptImageAttachments
|
||||||
attachments={imageAttachments()}
|
attachments={imageAttachments()}
|
||||||
onOpen={(attachment) =>
|
onOpen={(attachment) =>
|
||||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
|
||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
newLayoutDesigns={false}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
|
@ -2080,9 +1696,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
style={control()}
|
style={control()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dialog.show(() => (
|
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||||
<DialogSelectModelUnpaid model={props.controls.model.selection} />
|
|
||||||
))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
|
|
@ -2109,15 +1723,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.controls.model.selection}
|
model={props.controls.model.selection}
|
||||||
triggerAs={Button}
|
trigger={(triggerProps) => (
|
||||||
triggerProps={{
|
<Button
|
||||||
variant: "ghost",
|
{...triggerProps}
|
||||||
size: "normal",
|
variant="ghost"
|
||||||
style: control(),
|
size="normal"
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
style={control()}
|
||||||
"data-action": "prompt-model",
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
}}
|
data-action="prompt-model"
|
||||||
onClose={restoreFocus}
|
|
||||||
>
|
>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
|
|
@ -2131,7 +1744,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
language.t("dialog.model.select.title")}
|
language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</ModelSelectorPopover>
|
</Button>
|
||||||
|
)}
|
||||||
|
onClose={restoreFocus}
|
||||||
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2171,209 +1787,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComposerAgentControlState = {
|
|
||||||
title: string
|
|
||||||
keybind: string[]
|
|
||||||
options: string[]
|
|
||||||
current: string
|
|
||||||
style: JSX.CSSProperties | undefined
|
|
||||||
onSelect: (value: string | undefined) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComposerModelControlState = {
|
|
||||||
loading: boolean
|
|
||||||
shouldAnimate: boolean
|
|
||||||
paid: boolean
|
|
||||||
title: string
|
|
||||||
keybind: string[]
|
|
||||||
model: ReturnType<typeof useLocal>["model"]
|
|
||||||
providerID?: string
|
|
||||||
modelName: string
|
|
||||||
newLayoutDesigns: boolean
|
|
||||||
style: JSX.CSSProperties | undefined
|
|
||||||
onClose: () => void
|
|
||||||
onUnpaidClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{props.state.title}
|
|
||||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
|
||||||
<MenuV2.Trigger
|
|
||||||
as={ButtonV2}
|
|
||||||
data-action="prompt-agent"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="normal"
|
|
||||||
class="max-w-[175px] justify-start ![font-weight:440]"
|
|
||||||
style={props.state.style}
|
|
||||||
>
|
|
||||||
<span class="truncate capitalize leading-5">{props.state.current}</span>
|
|
||||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.Content>
|
|
||||||
<MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
|
|
||||||
{props.state.options.map((value) => (
|
|
||||||
<MenuV2.RadioItem value={value} class="capitalize">
|
|
||||||
{value}
|
|
||||||
</MenuV2.RadioItem>
|
|
||||||
))}
|
|
||||||
</MenuV2.RadioGroup>
|
|
||||||
</MenuV2.Content>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2>
|
|
||||||
</TooltipV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|
||||||
return (
|
|
||||||
<Show when={!props.state.loading}>
|
|
||||||
<Show
|
|
||||||
when={props.state.paid}
|
|
||||||
fallback={
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{props.state.title}
|
|
||||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.state.newLayoutDesigns}
|
|
||||||
fallback={
|
|
||||||
<Button
|
|
||||||
data-action="prompt-model"
|
|
||||||
as="div"
|
|
||||||
variant="ghost"
|
|
||||||
size="normal"
|
|
||||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
|
||||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
|
||||||
style={props.state.style}
|
|
||||||
onClick={props.state.onUnpaidClick}
|
|
||||||
>
|
|
||||||
<Show when={props.state.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">{props.state.modelName}</span>
|
|
||||||
<span class="-ml-1 shrink-0 flex size-fit">
|
|
||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ButtonV2
|
|
||||||
data-action="prompt-model"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="normal"
|
|
||||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
|
||||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
|
||||||
style={props.state.style}
|
|
||||||
onClick={props.state.onUnpaidClick}
|
|
||||||
>
|
|
||||||
<ModelControlContent state={props.state} v2 />
|
|
||||||
</ButtonV2>
|
|
||||||
</Show>
|
|
||||||
</TooltipV2>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{props.state.title}
|
|
||||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.state.newLayoutDesigns}
|
|
||||||
fallback={
|
|
||||||
<ModelSelectorPopover
|
|
||||||
model={props.state.model}
|
|
||||||
triggerAs={Button}
|
|
||||||
triggerProps={{
|
|
||||||
variant: "ghost",
|
|
||||||
size: "normal",
|
|
||||||
style: props.state.style,
|
|
||||||
class:
|
|
||||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
|
||||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
|
||||||
"data-action": "prompt-model",
|
|
||||||
}}
|
|
||||||
onClose={props.state.onClose}
|
|
||||||
>
|
|
||||||
<ModelControlContent state={props.state} />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ModelSelectorPopoverV2
|
|
||||||
model={props.state.model}
|
|
||||||
triggerAs={ButtonV2}
|
|
||||||
triggerProps={{
|
|
||||||
variant: "ghost-muted",
|
|
||||||
size: "normal",
|
|
||||||
style: props.state.style,
|
|
||||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
|
||||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
|
||||||
"data-action": "prompt-model",
|
|
||||||
}}
|
|
||||||
onClose={props.state.onClose}
|
|
||||||
>
|
|
||||||
<ModelControlContent state={props.state} v2 />
|
|
||||||
</ModelSelectorPopoverV2>
|
|
||||||
</Show>
|
|
||||||
</TooltipV2>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Show when={props.state.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.state.modelName}</span>
|
|
||||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
|
||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
id: Identifier.ascending("part"),
|
||||||
type: "text",
|
type: "text",
|
||||||
text: input.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)
|
||||||
|
|
|
||||||
57
packages/app/src/components/prompt-input/contracts.ts
Normal file
57
packages/app/src/components/prompt-input/contracts.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
63
packages/app/src/components/prompt-input/history-store.ts
Normal file
63
packages/app/src/components/prompt-input/history-store.ts
Normal file
|
|
@ -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 {
|
||||||
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async () => {
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
createdSessions.push(directory)
|
const location = input.location?.directory ?? directory
|
||||||
|
createdSessions.push(location)
|
||||||
|
sessionCreateInputs.push(input)
|
||||||
return {
|
return {
|
||||||
data: {
|
|
||||||
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 },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
shell: async () => {
|
prompt: async (input: unknown) => {
|
||||||
sentShell.push(directory)
|
sentPrompts.push(directory)
|
||||||
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
command: async (input: unknown) => {
|
||||||
promptAsync: async () => ({ data: undefined }),
|
sentCommands.push(input)
|
||||||
|
},
|
||||||
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
|
sentShell.push(input)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
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: {
|
||||||
|
id: input.draft.model.modelID,
|
||||||
|
providerID: input.draft.model.providerID,
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
parts: images.map((attachment) => ({
|
},
|
||||||
id: Identifier.ascending("part"),
|
files: await Promise.all(
|
||||||
type: "file" as const,
|
images.map(async (attachment) => ({
|
||||||
mime: attachment.mime,
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
url: attachment.dataUrl,
|
name: attachment.filename,
|
||||||
filename: 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,6 +356,7 @@ export function PromptProjectSelector(props: {
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="max-h-[224px] overflow-y-auto">
|
||||||
<Show
|
<Show
|
||||||
when={props.controller.servers().length > 1}
|
when={props.controller.servers().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
@ -387,7 +382,9 @@ export function PromptProjectSelector(props: {
|
||||||
{server!.name}
|
{server!.name}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
<For
|
||||||
|
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
|
||||||
|
>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -398,6 +395,7 @@ export function PromptProjectSelector(props: {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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">
|
||||||
<Show
|
<Show
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
value={branch()}
|
value={value()}
|
||||||
class="min-w-0 max-w-[220px]"
|
class="min-w-0 max-w-[220px]"
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
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]">
|
<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" />
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<span class="min-w-0 truncate">{branch()}</span>
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</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>
|
|
||||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
|
<Show when={props.canDefault && !props.isDefault}>
|
||||||
|
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.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,9 +745,6 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
|
||||||
when={props.v2}
|
|
||||||
fallback={
|
|
||||||
<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 flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
|
|
@ -512,43 +777,5 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
{groups}
|
{groups}
|
||||||
</div>
|
</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")}
|
|
||||||
</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">{groups}</div>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +221,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
|
<Show when={protocol() === "v1"}>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
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"
|
data-component="custom-provider-section"
|
||||||
|
|
@ -243,6 +247,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
{language.t("common.connect")}
|
{language.t("common.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
173
packages/app/src/components/settings-v2/general-controllers.ts
Normal file
173
packages/app/src/components/settings-v2/general-controllers.ts
Normal file
|
|
@ -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>
|
||||||
|
|
||||||
type ShellSelectOption = {
|
<SettingsRowV2
|
||||||
id: string
|
title={language.t("settings.general.row.theme.title")}
|
||||||
value: string
|
description={
|
||||||
label: string
|
<>
|
||||||
}
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
{language.t("common.learnMore")}
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
</ExternalLink>
|
||||||
const stopDemoSound = () => {
|
</>
|
||||||
demoSoundState.run += 1
|
|
||||||
if (demoSoundState.cleanup) {
|
|
||||||
demoSoundState.cleanup()
|
|
||||||
}
|
}
|
||||||
clearTimeout(demoSoundState.timeout)
|
>
|
||||||
demoSoundState.cleanup = undefined
|
<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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const playDemoSound = (id: string | undefined) => {
|
const FontSetting: Component<{
|
||||||
stopDemoSound()
|
kind: "ui" | "code" | "terminal"
|
||||||
if (!id) return
|
fonts: AppearanceSettingsController["fonts"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => fontSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action={config().action}
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={props.fonts[config().font]().value}
|
||||||
|
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||||
|
placeholder={props.fonts[config().font]().placeholder}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t(config().title)}
|
||||||
|
style={{ "font-family": props.fonts[config().font]().family }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const run = ++demoSoundState.run
|
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
const language = useLanguage()
|
||||||
void playSoundById(id).then((cleanup) => {
|
return (
|
||||||
if (demoSoundState.run !== run) {
|
<div class="settings-v2-section">
|
||||||
cleanup?.()
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||||
return
|
<SettingsListV2>
|
||||||
}
|
<SoundSetting kind="agent" channel={props.controller.agent} />
|
||||||
demoSoundState.cleanup = cleanup
|
<SoundSetting kind="permissions" channel={props.controller.permissions} />
|
||||||
})
|
<SoundSetting kind="errors" channel={props.controller.errors} />
|
||||||
}, 100)
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SoundSetting: Component<{
|
||||||
|
kind: "agent" | "permissions" | "errors"
|
||||||
|
channel: SoundSettingsController["agent"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => soundSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action={config().action}
|
||||||
|
options={soundOptions}
|
||||||
|
current={props.channel.current()}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => language.t(option.label)}
|
||||||
|
onHighlight={props.channel.highlight}
|
||||||
|
onSelect={props.channel.select}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageSetting = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const options = createMemo(() =>
|
||||||
|
language.locales.map((locale) => ({
|
||||||
|
value: locale,
|
||||||
|
label: language.label(locale),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<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,17 +102,56 @@ 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]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="settings-v2-section"
|
||||||
|
data-component="settings-models-provider"
|
||||||
|
data-expanded={expanded() ? "" : undefined}
|
||||||
|
>
|
||||||
|
<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
|
<ProviderIcon
|
||||||
id={group.category}
|
id={group.category}
|
||||||
width={PROVIDER_ICON_SIZE}
|
width={PROVIDER_ICON_SIZE}
|
||||||
height={PROVIDER_ICON_SIZE}
|
height={PROVIDER_ICON_SIZE}
|
||||||
class="settings-v2-models-provider-icon shrink-0"
|
class="settings-v2-models-provider-icon shrink-0"
|
||||||
/>
|
/>
|
||||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
|
||||||
</div>
|
</span>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<Show when={expanded()}>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
|
|
@ -127,8 +174,10 @@ export const SettingsModelsV2: Component = () => {
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</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,6 +224,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||||
<div class="settings-v2-provider-lead">
|
<div class="settings-v2-provider-lead">
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
|
|
@ -231,7 +238,9 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
<p class="settings-v2-provider-description">
|
||||||
|
{language.t("settings.providers.custom.description")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ButtonV2
|
<ButtonV2
|
||||||
|
|
@ -245,6 +254,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
{language.t("common.connect")}
|
{language.t("common.connect")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</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>
|
||||||
|
<Show when={protocol() === "v1"}>
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
{language.t("status.popover.tab.plugins")}
|
{language.t("status.popover.tab.plugins")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{!settings.general.newLayoutDesigns() && (
|
{!settings.general.newLayoutDesigns() && (
|
||||||
|
|
@ -478,6 +487,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Show when={protocol() === "v1"}>
|
||||||
<Tabs.Content value="plugins">
|
<Tabs.Content value="plugins">
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
|
|
@ -497,6 +507,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
</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,9 +240,10 @@ 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,
|
ptyID: id,
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
})
|
})
|
||||||
|
|
@ -251,6 +251,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
debugTerminal("failed to sync terminal size", err)
|
debugTerminal("failed to sync terminal size", err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return sdk()
|
||||||
|
.api.pty.update({
|
||||||
|
ptyID: id,
|
||||||
|
location: { directory },
|
||||||
|
size: { cols, rows },
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
debugTerminal("failed to sync terminal size", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getTerminalColors = (): TerminalColors => {
|
const getTerminalColors = (): TerminalColors => {
|
||||||
const mode = theme.mode() === "dark" ? "dark" : "light"
|
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||||
|
|
@ -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,18 +536,30 @@ 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()
|
||||||
|
.client.pty.get({ ptyID: id }, { throwOnError: false })
|
||||||
.then((result) => result.response.status === 404)
|
.then((result) => result.response.status === 404)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
debugTerminal("failed to inspect terminal session", err)
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return sdk()
|
||||||
|
.api.pty.get({ ptyID: id, location: { directory } })
|
||||||
|
.then((result) => result.data.status === "exited")
|
||||||
|
.catch((err) => {
|
||||||
|
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
||||||
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
const result = await client.pty
|
if ((await sdk().protocol) === "v1") {
|
||||||
.connectToken(
|
const result = await sdk()
|
||||||
|
.client.pty.connectToken(
|
||||||
{ ptyID: id, directory },
|
{ ptyID: id, directory },
|
||||||
{
|
{
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
|
|
@ -543,6 +577,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
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}`)
|
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) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { createMutation } from "@tanstack/solid-query"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||||
|
|
@ -23,8 +22,7 @@ export function TabNavItem(props: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => Session | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onTitleChange?: (title: string) => void
|
onRename: (title: string) => Promise<void>
|
||||||
onTitleChangeFailed?: (title: string) => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
|
@ -34,13 +32,12 @@ export function TabNavItem(props: {
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||||
let tabRoot!: HTMLDivElement
|
let tabRoot!: HTMLDivElement
|
||||||
let titleEl!: HTMLSpanElement
|
let titleEl!: HTMLSpanElement
|
||||||
let committing = false
|
|
||||||
let measureFrame: number | undefined
|
let measureFrame: number | undefined
|
||||||
|
const rename = createMutation(() => ({ mutationFn: props.onRename }))
|
||||||
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -116,41 +113,20 @@ export function TabNavItem(props: {
|
||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rename = async (title: string) => {
|
|
||||||
const ctx = serverCtx()
|
|
||||||
const session = props.session()
|
|
||||||
if (!ctx || !session) return
|
|
||||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
|
||||||
await client.session.update({ sessionID: session.id, title })
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
if (committing || !editing()) return
|
if (rename.isPending || !editing()) return
|
||||||
committing = true
|
|
||||||
|
|
||||||
const original = props.session()?.title ?? ""
|
const original = props.session()?.title ?? ""
|
||||||
const next = (titleEl.textContent ?? "").trim()
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
titleEl.scrollLeft = 0
|
titleEl.scrollLeft = 0
|
||||||
if (save && next && next !== original) props.onTitleChange?.(next)
|
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
|
||||||
if (!save || !next || next === original) {
|
if (!save || !next || next === original) {
|
||||||
committing = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await rename.mutateAsync(next)
|
||||||
await rename(next)
|
|
||||||
} catch (err) {
|
|
||||||
props.onTitleChangeFailed?.(original)
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
committing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -164,7 +140,7 @@ export function TabNavItem(props: {
|
||||||
const openRename = (event: MouseEvent) => {
|
const openRename = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
titleEl.textContent = session.title
|
titleEl.textContent = session.title
|
||||||
|
|
@ -204,11 +180,11 @@ export function TabNavItem(props: {
|
||||||
data-slot="titlebar-tab-item"
|
data-slot="titlebar-tab-item"
|
||||||
data-title-overflow={titleOverflowing()}
|
data-title-overflow={titleOverflowing()}
|
||||||
data-editing={editing()}
|
data-editing={editing()}
|
||||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -219,7 +195,6 @@ export function TabNavItem(props: {
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={title() !== undefined}>
|
|
||||||
<a
|
<a
|
||||||
data-slot="tab-link"
|
data-slot="tab-link"
|
||||||
data-titlebar-tab-link
|
data-titlebar-tab-link
|
||||||
|
|
@ -249,6 +224,7 @@ export function TabNavItem(props: {
|
||||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||||
<Show
|
<Show
|
||||||
when={props.session()}
|
when={props.session()}
|
||||||
|
keyed
|
||||||
fallback={
|
fallback={
|
||||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
|
|
@ -256,8 +232,8 @@ export function TabNavItem(props: {
|
||||||
{(session) => (
|
{(session) => (
|
||||||
<SessionTabAvatar
|
<SessionTabAvatar
|
||||||
project={project()}
|
project={project()}
|
||||||
directory={session().directory}
|
directory={session.directory}
|
||||||
sessionId={session().id}
|
sessionId={session.id}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -300,9 +276,8 @@ export function TabNavItem(props: {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<div data-slot="tab-close">
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
|
|
@ -360,8 +335,8 @@ export function DraftTabItem(props: {
|
||||||
data-slot="titlebar-tab-item"
|
data-slot="titlebar-tab-item"
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
|
|
@ -407,7 +382,7 @@ export function DraftTabItem(props: {
|
||||||
{props.title}
|
{props.title}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<div data-slot="tab-close">
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue