mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 368f2c8433 | |||
| b751e26565 | |||
| 1612baf254 | |||
| ef8f238c49 | |||
| 93e186b5df | |||
| adba484df4 | |||
| 37c263e153 | |||
| 204f48de8b | |||
| 743f6410f2 | |||
| 20589d66d5 | |||
| 62e4641235 | |||
| e59ba24b80 | |||
| 347510a73b | |||
| d03e0c5e54 | |||
| 84c79c1399 | |||
| e45210c6d2 | |||
| 92cede0541 | |||
| fada1a538f | |||
| 542ba88602 | |||
| 411eff73f0 | |||
| 50eee1f5a4 | |||
| 0a601cf334 | |||
| c9db6e9a1f | |||
| 130038eb63 | |||
| 4438f69aac | |||
| 76ced5418f | |||
| c40e3e74a7 | |||
| 0317531906 | |||
| 5f241f1cc1 | |||
| b513fafe83 | |||
| 21c4c93770 | |||
| abc4b83f58 | |||
| cb562b2c62 | |||
| 2d2339d37d | |||
| 849c2598ab | |||
| 3033afba51 | |||
| 5a8ee27254 | |||
| 4cc022481c | |||
| d36a2d8981 | |||
| 96ff82a11f | |||
| 4872c48c23 | |||
| 47fc6f2991 | |||
| da312b009c | |||
| 70535a1bad | |||
| 43e472bba7 | |||
| b67fda133a | |||
| e2531dff0f | |||
| cc3615b535 | |||
| 5542415b64 | |||
| 8bec40a816 | |||
| 4a81e8392b | |||
| a19b52e85b | |||
| 9105f35966 | |||
| 3ba3954aa0 | |||
| 3fc5af6dd1 | |||
| 5586f9675e | |||
| 3b719aba48 | |||
| 20a3a2138e | |||
| cd46f22d51 | |||
| cfa134f042 | |||
| 61653a20a5 | |||
| 2a097f3af7 | |||
| ba4b8e21f4 | |||
| 7985c2066a | |||
| 67caf894e0 | |||
| 78587c141b | |||
| f5573281ca | |||
| 4817228f96 | |||
| 5a5117b36c | |||
| 6a288426b8 | |||
| 573ac85983 | |||
| 1221bbbe14 | |||
| 6bea0f4fa4 | |||
| cc34084dfe | |||
| b8142c7aa8 | |||
| ba3f966ed0 | |||
| b95fe7b2d7 | |||
| 9da9ed9de6 | |||
| fab2133129 | |||
| 901c9e7329 | |||
| 3476e6baa5 | |||
| 86e04d4174 | |||
| 45cd8d7692 | |||
| a46374e90e | |||
| 41405c9eb0 | |||
| 49d2dd8a38 | |||
| 0df2f6245a | |||
| 38989c57a0 | |||
| 69a80663a2 | |||
| 518b0bf9e3 | |||
| be08207a88 | |||
| efb6cc2d4b | |||
| 4bffbb655f | |||
| ba6cf38607 | |||
| 86978e7a8c | |||
| 82a3270cf2 | |||
| c0a258b22a | |||
| ed926be253 | |||
| b527f605d9 | |||
| aca52ba198 | |||
| f7d5a1cbc7 | |||
| 3238daa851 | |||
| 90291fc6df | |||
| a414e06d0c | |||
| 52f65f3a0b | |||
| 3a1c6df9e2 | |||
| 4436678f7d | |||
| 08fb473735 | |||
| 51213520f5 | |||
| 0bc9a28b5e | |||
| 1d2a7b4c86 | |||
| 453b61e27b | |||
| 95ebf50ace | |||
| d8a1b932eb | |||
| 0c91e6a620 | |||
| ef3b673084 | |||
| c69abee0c7 | |||
| dc3ff4b7aa | |||
| 958e08e109 | |||
| 590a11ed98 | |||
| 17544802c3 | |||
| 4a760b5743 | |||
| 888c4cb504 | |||
| 4394b324c9 | |||
| a27ffb20f0 | |||
| 81141dc562 | |||
| 285d315b4e | |||
| 656f299017 | |||
| 04bdf7732b | |||
| ab7dfb1edc | |||
| 05c3e40a4e | |||
| b6478dcebf | |||
| 571e7b852f | |||
| 888dd26e1d | |||
| 279c8bdd7f | |||
| 865e4d5c86 | |||
| 64950cd358 | |||
| 36157938a8 | |||
| c0dea334fb | |||
| 9b49daf3a9 | |||
| d6f1b08ce8 | |||
| 75cf4cc8a8 | |||
| 4a181c3574 | |||
| bfddf05782 | |||
| cdee625a13 | |||
| 5f7091ab4e | |||
| e7e7a97648 | |||
| f58b8cb67b | |||
| 775f687ca9 | |||
| 265a93927d | |||
| 3a938bb6d8 | |||
| 6f9a4e70da | |||
| dedb4133dc | |||
| 21f8184c58 | |||
| 59fe350972 | |||
| 5bdfcaac3c | |||
| 323d687173 | |||
| cdca203be2 | |||
| 77a31d2b6e | |||
| a1ed99278a | |||
| 729bd438e4 | |||
| 7304bb2751 | |||
| 456521f689 | |||
| ee69079bb4 | |||
| de14bb2e0f | |||
| 0539d56fc4 | |||
| 729adde022 | |||
| b7111d5c62 | |||
| 1669713fbc | |||
| cb8be9ba12 | |||
| 15046658ba | |||
| 7255ce9a57 | |||
| 2b9cf5d388 | |||
| a625d35f7b | |||
| 449c64928b | |||
| 35c88c3fc8 | |||
| b5e09024d5 | |||
| 9a51765bd2 | |||
| bb31c9b92c | |||
| 5117679148 | |||
| 0d5798c0bf | |||
| 808677a612 | |||
| fe41e9d482 | |||
| 0768c4655e | |||
| 1586a26594 | |||
| f026c1422c | |||
| b7fdb595df | |||
| 049ee1ce5e | |||
| e71fbb6d48 | |||
| c9976d69c0 | |||
| 27734409e4 | |||
| e434ce01d3 | |||
| c2f93ae2ad | |||
| 92cc8f553a | |||
| 49d997aec3 | |||
| 67aa9cee23 | |||
| 7ec3d67829 | |||
| 5401ebaede | |||
| 99668cfdce |
@@ -18,4 +18,4 @@ simonklee
|
|||||||
Slickstef11
|
Slickstef11
|
||||||
usrnk1
|
usrnk1
|
||||||
vimtor
|
vimtor
|
||||||
starptech
|
StarpTech
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"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.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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -294,7 +295,7 @@
|
|||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.88",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
@@ -302,8 +303,8 @@
|
|||||||
"@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.53",
|
"@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",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
@@ -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.10.0",
|
"gitlab-ai-provider": "6.11.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,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -460,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -569,7 +570,7 @@
|
|||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.88",
|
||||||
"@ai-sdk/cerebras": "2.0.60",
|
"@ai-sdk/cerebras": "2.0.60",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
@@ -577,8 +578,8 @@
|
|||||||
"@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.53",
|
"@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",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
@@ -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.10.0",
|
"gitlab-ai-provider": "6.11.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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"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.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.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:*",
|
||||||
@@ -839,7 +841,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -852,9 +854,10 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
@@ -885,7 +888,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"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",
|
||||||
@@ -904,7 +907,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -936,6 +939,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",
|
||||||
@@ -945,7 +949,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -972,7 +976,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1023,7 +1027,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.18",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1066,17 +1070,17 @@
|
|||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.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",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.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",
|
||||||
"@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",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.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",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -1099,9 +1103,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",
|
||||||
@@ -1112,7 +1116,7 @@
|
|||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
@@ -1132,7 +1136,7 @@
|
|||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
@@ -1174,7 +1178,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
|
||||||
|
|
||||||
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
||||||
|
|
||||||
@@ -1184,7 +1188,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||||
|
|
||||||
"@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-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
||||||
|
|
||||||
@@ -1198,7 +1202,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 +2042,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=="],
|
||||||
|
|
||||||
@@ -2780,9 +2784,9 @@
|
|||||||
|
|
||||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
||||||
|
|
||||||
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
|
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
|
||||||
|
|
||||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||||
|
|
||||||
@@ -3844,13 +3848,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.10.0", "", { "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-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
"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=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
@@ -4312,7 +4316,7 @@
|
|||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||||
|
|
||||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||||
|
|
||||||
@@ -5654,7 +5658,11 @@
|
|||||||
|
|
||||||
"@ai-sdk/anthropic/@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-sdk/anthropic/@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-sdk/azure/@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/azure/@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=="],
|
||||||
|
|
||||||
|
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras/@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/cerebras/@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=="],
|
||||||
|
|
||||||
@@ -5666,9 +5674,9 @@
|
|||||||
|
|
||||||
"@ai-sdk/deepinfra/@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/deepinfra/@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/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
"@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-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
@@ -5694,7 +5702,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=="],
|
||||||
|
|
||||||
@@ -6020,7 +6030,9 @@
|
|||||||
|
|
||||||
"@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/core/@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=="],
|
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
@@ -6038,6 +6050,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.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
@@ -6046,6 +6060,8 @@
|
|||||||
|
|
||||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||||
@@ -6162,6 +6178,12 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
@@ -6388,7 +6410,7 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||||
|
|
||||||
"opencode/@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=="],
|
"opencode/@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-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-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=="],
|
||||||
|
|
||||||
@@ -6896,6 +6918,10 @@
|
|||||||
|
|
||||||
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
@@ -6966,6 +6992,16 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@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/anthropic/@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/azure/@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-gateway-provider/@ai-sdk/azure/@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/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
@@ -7118,6 +7154,10 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
@@ -7366,6 +7406,8 @@
|
|||||||
|
|
||||||
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||||
@@ -7382,6 +7424,12 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@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=="],
|
||||||
@@ -7452,6 +7500,8 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
|||||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
export const deployAws = $app.stage === awsStage
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
|
if ($app.stage === "production") {
|
||||||
|
new cloudflare.DnsRecord("TrustCenter", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "trust.opencode.ai",
|
||||||
|
type: "CNAME",
|
||||||
|
content: "3a69a5bb27875189.vercel-dns-016.com",
|
||||||
|
proxied: false,
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
new cloudflare.DnsRecord("TrustCenterVerification", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "opencode.ai",
|
||||||
|
type: "TXT",
|
||||||
|
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
regionKey: "us",
|
regionKey: "us",
|
||||||
|
|||||||
+66
-33
@@ -8,6 +8,8 @@
|
|||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
autoPatchelfHook,
|
autoPatchelfHook,
|
||||||
|
copyDesktopItems,
|
||||||
|
makeDesktopItem,
|
||||||
opencode,
|
opencode,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
nodejs
|
nodejs
|
||||||
makeWrapper
|
makeWrapper
|
||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
copyDesktopItems
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
darwin.autoSignDarwinBinariesHook
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
(lib.getLib stdenv.cc.cc)
|
(lib.getLib stdenv.cc.cc)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
|
||||||
|
name = "ai.opencode.desktop";
|
||||||
|
desktopName = "OpenCode";
|
||||||
|
exec = "opencode-desktop %U";
|
||||||
|
icon = "ai.opencode.desktop";
|
||||||
|
# Electron 41 derives X11 WM_CLASS from app.name.
|
||||||
|
startupWMClass = "OpenCode";
|
||||||
|
categories = [ "Development" ];
|
||||||
|
});
|
||||||
|
|
||||||
env = opencode.env // {
|
env = opencode.env // {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
# https://github.com/electron/electron/issues/31121
|
postPatch =
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||||
postPatch = lib.optionalString stdenv.isLinux ''
|
''
|
||||||
BASE_PATH=packages/desktop
|
substituteInPlace packages/script/src/index.ts \
|
||||||
FILES=(src/main/windows.ts)
|
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||||
for file in "''${FILES[@]}"; do
|
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||||
substituteInPlace $BASE_PATH/$file \
|
''
|
||||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
# https://github.com/electron/electron/issues/31121
|
||||||
done
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
'';
|
+ lib.optionalString stdenv.isLinux ''
|
||||||
|
BASE_PATH=packages/desktop
|
||||||
|
FILES=(src/main/windows.ts)
|
||||||
|
for file in "''${FILES[@]}"; do
|
||||||
|
substituteInPlace $BASE_PATH/$file \
|
||||||
|
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||||
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
runHook preInstall
|
||||||
runHook preInstall
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
mkdir -p $out/Applications
|
||||||
mkdir -p $out/Applications
|
mv dist/mac*/*.app $out/Applications
|
||||||
mv dist/mac*/*.app $out/Applications
|
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
mkdir -p $out/opt/opencode-desktop
|
||||||
mkdir -p $out/opt/opencode-desktop
|
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
install -Dm644 resources/icons/32x32.png \
|
||||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||||
--inherit-argv0 \
|
install -Dm644 resources/icons/64x64.png \
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
install -Dm644 resources/icons/128x128.png \
|
||||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
||||||
''
|
install -Dm644 resources/icons/128x128@2x.png \
|
||||||
+ ''
|
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
||||||
runHook postInstall
|
install -Dm644 resources/icons/icon.png \
|
||||||
'';
|
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
||||||
|
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
||||||
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
|
--inherit-argv0 \
|
||||||
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
|
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||||
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||||
|
''
|
||||||
|
+ ''
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
autoPatchelfIgnoreMissingDeps = [
|
autoPatchelfIgnoreMissingDeps = [
|
||||||
"libc.musl-x86_64.so.1"
|
"libc.musl-x86_64.so.1"
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||||
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||||
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||||
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -40,10 +40,10 @@
|
|||||||
"@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.28",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
@@ -149,13 +149,13 @@
|
|||||||
"@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",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.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"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -136,6 +138,9 @@ export async function setupTimeline(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
if (settings.newLayoutDesigns === false) {
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
|
}
|
||||||
}, input.settings ?? {})
|
}, input.settings ?? {})
|
||||||
if (input.locale) {
|
if (input.locale) {
|
||||||
await page.addInitScript((locale) => {
|
await page.addInitScript((locale) => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
|
||||||
|
const serverA = "http://127.0.0.1:4096"
|
||||||
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
const directoryA = "C:/server-a"
|
||||||
|
const directoryB = "/home/server-b"
|
||||||
|
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
||||||
|
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
||||||
|
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||||
|
|
||||||
|
test("session settings use the remote server context", async ({ page }) => {
|
||||||
|
const permissionRequests: string[] = []
|
||||||
|
await mockServers(page, permissionRequests)
|
||||||
|
await configureServers(page)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||||
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||||
|
|
||||||
|
const dialog = page.locator(".settings-v2-dialog")
|
||||||
|
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
|
const input = autoAccept.getByRole("switch")
|
||||||
|
await expect(autoAccept).toBeVisible()
|
||||||
|
await expect(input).toBeEnabled()
|
||||||
|
permissionRequests.length = 0
|
||||||
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toBeChecked()
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
permissionRequests.some((request) => {
|
||||||
|
const url = new URL(request)
|
||||||
|
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
||||||
|
|
||||||
|
await dialog.getByRole("tab", { name: "Models" }).click()
|
||||||
|
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||||
|
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||||
|
const permissionRequests: string[] = []
|
||||||
|
const permissionResponses: PermissionResponse[] = []
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
|
server: serverA,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockServers(page, permissionRequests, permissionResponses)
|
||||||
|
await configureServers(page, [
|
||||||
|
{ type: "session", server: serverA, sessionId: sessionA.id },
|
||||||
|
{ type: "session", server: serverB, sessionId: sessionB.id },
|
||||||
|
])
|
||||||
|
|
||||||
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
|
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||||
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||||
|
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
permissionRequests.some((request) => {
|
||||||
|
const url = new URL(request)
|
||||||
|
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
|
await transport.waitForConnection()
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory: directoryA,
|
||||||
|
payload: {
|
||||||
|
id: "event-permission-background-a",
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "permission-background-a",
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git status"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => permissionResponses)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permissionID: "permission-background-a",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory: directoryA,
|
||||||
|
payload: {
|
||||||
|
id: "event-permission-background-a-child",
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "permission-background-a-child",
|
||||||
|
sessionID: childSessionA.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git diff"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => permissionResponses)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permissionID: "permission-background-a",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: childSessionA.id,
|
||||||
|
permissionID: "permission-background-a-child",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
type PermissionResponse = {
|
||||||
|
origin: string
|
||||||
|
directory?: string
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
body: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ serverB, tabs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
||||||
|
},
|
||||||
|
{ serverB, tabs },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||||
|
const remote = url.origin === serverB
|
||||||
|
const directory = remote ? directoryB : directoryA
|
||||||
|
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||||
|
const requestDirectory = url.searchParams.get("directory")
|
||||||
|
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||||
|
if (route.request().method() === "POST" && response) {
|
||||||
|
permissionResponses.push({
|
||||||
|
origin: url.origin,
|
||||||
|
directory: requestDirectory ?? undefined,
|
||||||
|
sessionID: response[1]!,
|
||||||
|
permissionID: response[2]!,
|
||||||
|
body: route.request().postDataJSON(),
|
||||||
|
})
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
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/health") return json(route, { healthy: true })
|
||||||
|
if (url.pathname === "/session/status") return json(route, {})
|
||||||
|
if (url.pathname === "/session") return json(route, sessions)
|
||||||
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
|
if (current) return json(route, current)
|
||||||
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
|
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||||
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
|
if (url.pathname === "/permission") {
|
||||||
|
permissionRequests.push(url.toString())
|
||||||
|
return json(route, [])
|
||||||
|
}
|
||||||
|
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||||
|
return json(route, [])
|
||||||
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
|
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||||
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||||
|
const project = {
|
||||||
|
id: remote ? sessionB.projectID : "project-server-a",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [],
|
||||||
|
}
|
||||||
|
return json(route, url.pathname === "/project" ? [project] : project)
|
||||||
|
}
|
||||||
|
if (url.pathname === "/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" })
|
||||||
|
return json(route, {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, directory: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: `project-${id}`,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(id: string) {
|
||||||
|
const name = id === "server-b" ? "Server B" : "Server A"
|
||||||
|
return {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: `${name} Provider`,
|
||||||
|
models: {
|
||||||
|
[id]: {
|
||||||
|
id,
|
||||||
|
name: `${name} Model`,
|
||||||
|
family: id,
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: [id],
|
||||||
|
default: { providerID: id, modelID: id },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown, status = 200) {
|
||||||
|
return route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
@@ -105,5 +105,9 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route) {
|
function sse(route: Route) {
|
||||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
|||||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/ReviewTabSwitch"
|
const directory = "C:/OpenCode/ReviewTabSwitch"
|
||||||
|
const otherDirectory = "C:/OpenCode/ReviewTabSwitchOther"
|
||||||
const projectID = "proj_review_tab_switch"
|
const projectID = "proj_review_tab_switch"
|
||||||
const sessionA = "ses_review_tab_a"
|
const sessionA = "ses_review_tab_a"
|
||||||
const sessionB = "ses_review_tab_b"
|
const sessionB = "ses_review_tab_b"
|
||||||
@@ -58,6 +59,23 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
|||||||
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps the v2 review pane mounted when switching session tabs across worktrees", async ({ page }) => {
|
||||||
|
await setup(page, otherDirectory)
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sessionA))
|
||||||
|
await expectSessionTitle(page, titleA)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
|
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||||
|
await expectAppVisible(review)
|
||||||
|
await writeProbe(page)
|
||||||
|
|
||||||
|
await switchTab(page, titleB)
|
||||||
|
await expectSessionTitle(page, titleB)
|
||||||
|
await expectAppVisible(review)
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
})
|
||||||
|
|
||||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||||
|
|
||||||
async function switchTab(page: Page, title: string) {
|
async function switchTab(page: Page, title: string) {
|
||||||
@@ -74,7 +92,7 @@ async function readProbe(page: Page) {
|
|||||||
return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
|
return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page, destinationDirectory = directory) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -96,19 +114,22 @@ async function setup(page: Page) {
|
|||||||
connected: ["opencode"],
|
connected: ["opencode"],
|
||||||
default: { providerID: "opencode", modelID: "test" },
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
},
|
},
|
||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [
|
||||||
|
session(sessionA, titleA, 1700000000000, directory),
|
||||||
|
session(sessionB, titleB, 1700000001000, destinationDirectory),
|
||||||
|
],
|
||||||
vcsDiff: diffs,
|
vcsDiff: diffs,
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessions }) => {
|
({ directories, server, sessions }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
projects: { local: directories.map((worktree: string) => ({ worktree, expanded: true })) },
|
||||||
lastProject: { local: directory },
|
lastProject: { local: directories[0] },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -116,16 +137,16 @@ async function setup(page: Page) {
|
|||||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ directory, server, sessions: [sessionA, sessionB] },
|
{ directories: [...new Set([directory, destinationDirectory])], server, sessions: [sessionA, sessionB] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function session(id: string, title: string, created: number) {
|
function session(id: string, title: string, created: number, worktree: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
slug: id,
|
slug: id,
|
||||||
projectID,
|
projectID,
|
||||||
directory,
|
directory: worktree,
|
||||||
title,
|
title,
|
||||||
version: "dev",
|
version: "dev",
|
||||||
time: { created, updated: created },
|
time: { created, updated: created },
|
||||||
|
|||||||
@@ -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 } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/RequestDocks"
|
const directory = "C:/OpenCode/RequestDocks"
|
||||||
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
|
const transport = await installSseTransport(page, {
|
||||||
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockServer(page, { questions: [] })
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
|
const draft = "keep the caret at the end"
|
||||||
|
await editor.fill(draft)
|
||||||
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
||||||
|
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
|
||||||
|
const cursor = draft.length - 4
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
editor.evaluate((element) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
|
||||||
|
const range = selection.getRangeAt(0).cloneRange()
|
||||||
|
range.selectNodeContents(element)
|
||||||
|
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||||
|
return range.toString().length
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(cursor)
|
||||||
|
await transport.send({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "question.asked",
|
||||||
|
properties: {
|
||||||
|
id: "question-caret",
|
||||||
|
sessionID,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Continue",
|
||||||
|
question: "Continue?",
|
||||||
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
|
await expect(question).toBeVisible()
|
||||||
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory,
|
||||||
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
|
})
|
||||||
|
await expect(question).toHaveCount(0)
|
||||||
|
await expect(editor).toBeVisible()
|
||||||
|
await page.keyboard.press("x")
|
||||||
|
|
||||||
|
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
|
||||||
|
})
|
||||||
|
|
||||||
async function mockServer(
|
async function mockServer(
|
||||||
page: Page,
|
page: Page,
|
||||||
requests: {
|
requests: {
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
|||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const assistants = Array.from({ length: 14 }, (_, index) =>
|
const initialPageSize = 20
|
||||||
|
const historyPageSize = 200
|
||||||
|
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
parentID: userID,
|
parentID: userID,
|
||||||
created: 1700000001000 + index * 1_000,
|
created: 1700000001000 + index * 1_000,
|
||||||
completed: index < 13,
|
completed: index < initialPageSize,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const messages = [userMessage(), ...assistants]
|
const messages = [userMessage(), ...assistants]
|
||||||
@@ -46,7 +48,7 @@ const scenarios = [
|
|||||||
test.use({ viewport: { width: 646, height: 1385 } })
|
test.use({ viewport: { width: 646, height: 1385 } })
|
||||||
|
|
||||||
for (const scenario of scenarios) {
|
for (const scenario of scenarios) {
|
||||||
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
||||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||||
const pages: { before?: string; limit: number }[] = []
|
const pages: { before?: string; limit: number }[] = []
|
||||||
const roots: { sessionID: string; messageID: string }[] = []
|
const roots: { sessionID: string; messageID: string }[] = []
|
||||||
@@ -101,36 +103,51 @@ for (const scenario of scenarios) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.addInitScript(
|
await page.addInitScript(() => {
|
||||||
({ userPartID, lastPartID }) => {
|
const visibleParts = () => {
|
||||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
const sample = () => {
|
const view = viewport?.getBoundingClientRect()
|
||||||
if (state.armed) {
|
if (!viewport || !view) return []
|
||||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
.filter((part) => {
|
||||||
const view = viewport?.getBoundingClientRect()
|
const rect = part.getBoundingClientRect()
|
||||||
const visible = (partID: string) => {
|
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
})
|
||||||
const rect = part?.getBoundingClientRect()
|
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
||||||
return (
|
}
|
||||||
!!rect &&
|
const state = {
|
||||||
!!view &&
|
armed: false,
|
||||||
rect.width > 0 &&
|
hidden: false,
|
||||||
rect.height > 0 &&
|
visibleParts: [] as string[],
|
||||||
rect.bottom > view.top &&
|
samples: 0,
|
||||||
rect.top < view.bottom
|
stop: false,
|
||||||
)
|
arm() {
|
||||||
}
|
state.visibleParts = visibleParts()
|
||||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
state.armed = true
|
||||||
state.samples++
|
},
|
||||||
|
}
|
||||||
|
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||||
|
const sample = () => {
|
||||||
|
if (state.armed) {
|
||||||
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
const visible = (partID: string) => {
|
||||||
|
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
||||||
|
const rect = part?.getBoundingClientRect()
|
||||||
|
return (
|
||||||
|
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
||||||
|
state.hidden = true
|
||||||
|
state.samples++
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
},
|
}
|
||||||
{ userPartID, lastPartID },
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
)
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
@@ -143,23 +160,28 @@ for (const scenario of scenarios) {
|
|||||||
"messages:start:latest",
|
"messages:start:latest",
|
||||||
"messages:end:latest",
|
"messages:end:latest",
|
||||||
`message:${userID}`,
|
`message:${userID}`,
|
||||||
`messages:start:${messages.at(-2)!.info.id}`,
|
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||||
])
|
])
|
||||||
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
;(
|
;(
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__historyRootProbe?: { armed: boolean }
|
__historyRootProbe?: { arm(): void }
|
||||||
}
|
}
|
||||||
).__historyRootProbe!.armed = true
|
).__historyRootProbe!.arm()
|
||||||
})
|
})
|
||||||
await waitForProbeSamples(page, 0)
|
await waitForProbeSamples(page, 0)
|
||||||
expect(await historyRootHidden(page)).toBe(false)
|
expect(await visibleContentHidden(page)).toBe(false)
|
||||||
const beforeHistory = await probeSamples(page)
|
const beforeHistory = await probeSamples(page)
|
||||||
history.resolve()
|
history.resolve()
|
||||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
||||||
|
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
await waitForProbeSamples(page, beforeHistory)
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
expect(pages).toEqual([
|
||||||
|
{ before: undefined, limit: initialPageSize },
|
||||||
|
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||||
|
])
|
||||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||||
|
|
||||||
const message = messageUpdated(scenario.info)
|
const message = messageUpdated(scenario.info)
|
||||||
@@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyRootHidden(page: Page) {
|
function visibleContentHidden(page: Page) {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
|
|||||||
const projectID = "proj_terminal_composer_focus"
|
const projectID = "proj_terminal_composer_focus"
|
||||||
const sessionID = "ses_terminal_composer_focus"
|
const sessionID = "ses_terminal_composer_focus"
|
||||||
const ptyID = "pty_terminal_composer_focus"
|
const ptyID = "pty_terminal_composer_focus"
|
||||||
|
const newPtyID = "pty_terminal_composer_focus_new"
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
|
|||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||||
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")
|
||||||
|
|
||||||
@@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
|
|||||||
await expect(composer).toBeFocused()
|
await expect(composer).toBeFocused()
|
||||||
await expect(composer).toHaveText("a")
|
await expect(composer).toHaveText("a")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
||||||
|
const ghostty = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
const created = { count: 0 }
|
||||||
|
await page.route("**/pty", (route) => {
|
||||||
|
created.count += 1
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
ghostty.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
await seedCachedTerminal(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
expect(created.count).toBe(0)
|
||||||
|
await ghostty.promise
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
release.resolve()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
||||||
|
const ghostty = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
ghostty.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await ghostty.promise
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
release.resolve()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await page.waitForTimeout(50)
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
|
const created = { count: 0 }
|
||||||
|
await page.route("**/pty", (route) => {
|
||||||
|
created.count += 1
|
||||||
|
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(next),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/pty/${newPtyID}`, (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
|
)
|
||||||
|
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New terminal" }).click()
|
||||||
|
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function seedCachedTerminal(page: Page) {
|
||||||
|
return page.addInitScript(
|
||||||
|
({ terminalKey, ptyID }) => {
|
||||||
|
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
terminalKey,
|
||||||
|
JSON.stringify({
|
||||||
|
active: ptyID,
|
||||||
|
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
|||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/TerminalTabSwitch"
|
const directory = "C:/OpenCode/TerminalTabSwitch"
|
||||||
|
const otherDirectory = "C:/OpenCode/TerminalTabSwitchOther"
|
||||||
const projectID = "proj_terminal_tab_switch"
|
const projectID = "proj_terminal_tab_switch"
|
||||||
const sessionA = "ses_terminal_tab_a"
|
const sessionA = "ses_terminal_tab_a"
|
||||||
const sessionB = "ses_terminal_tab_b"
|
const sessionB = "ses_terminal_tab_b"
|
||||||
@@ -44,6 +45,29 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
|||||||
expect(connections.length).toBe(1)
|
expect(connections.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps the terminal pane mounted when switching session tabs across worktrees", async ({ page }) => {
|
||||||
|
await setup(page, otherDirectory)
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sessionB))
|
||||||
|
await expectSessionTitle(page, titleB)
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||||
|
|
||||||
|
await switchTab(page, titleA)
|
||||||
|
await expectSessionTitle(page, titleA)
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
|
||||||
|
const panel = page.locator("#terminal-panel")
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
await panel.evaluate((el, probe) => {
|
||||||
|
;(el as Probed).__e2eProbe = probe
|
||||||
|
}, PROBE)
|
||||||
|
|
||||||
|
await switchTab(page, titleB)
|
||||||
|
await expectSessionTitle(page, titleB)
|
||||||
|
expect(await panel.evaluate((el) => (el as Probed).__e2eProbe)).toBe(PROBE)
|
||||||
|
})
|
||||||
|
|
||||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||||
|
|
||||||
async function switchTab(page: Page, title: string) {
|
async function switchTab(page: Page, title: string) {
|
||||||
@@ -60,7 +84,7 @@ async function readProbe(page: Page) {
|
|||||||
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
|
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page, destinationDirectory = directory) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -82,7 +106,10 @@ async function setup(page: Page) {
|
|||||||
connected: ["opencode"],
|
connected: ["opencode"],
|
||||||
default: { providerID: "opencode", modelID: "test" },
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
},
|
},
|
||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [
|
||||||
|
session(sessionA, titleA, 1700000000000, directory),
|
||||||
|
session(sessionB, titleB, 1700000001000, destinationDirectory),
|
||||||
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/pty", (route) =>
|
||||||
@@ -109,13 +136,13 @@ async function setup(page: Page) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessions }) => {
|
({ directories, server, sessions }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
projects: { local: directories.map((worktree: string) => ({ worktree, expanded: true })) },
|
||||||
lastProject: { local: directory },
|
lastProject: { local: directories[0] },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -123,17 +150,17 @@ async function setup(page: Page) {
|
|||||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ directory, server, sessions: [sessionA, sessionB] },
|
{ directories: [...new Set([directory, destinationDirectory])], server, sessions: [sessionA, sessionB] },
|
||||||
)
|
)
|
||||||
return connections
|
return connections
|
||||||
}
|
}
|
||||||
|
|
||||||
function session(id: string, title: string, created: number) {
|
function session(id: string, title: string, created: number, worktree: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
slug: id,
|
slug: id,
|
||||||
projectID,
|
projectID,
|
||||||
directory,
|
directory: worktree,
|
||||||
title,
|
title,
|
||||||
version: "dev",
|
version: "dev",
|
||||||
time: { created, updated: created },
|
time: { created, updated: created },
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Timeline Suspense Reproduction</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #171717;
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="root"></main>
|
||||||
|
<script type="module" src="/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
||||||
|
import { render } from "solid-js/web"
|
||||||
|
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
||||||
|
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
||||||
|
|
||||||
|
const rowCount = 2_000
|
||||||
|
const rowHeight = 40
|
||||||
|
const parameters = new URLSearchParams(location.search)
|
||||||
|
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
||||||
|
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
||||||
|
|
||||||
|
type MutationEvent = {
|
||||||
|
kind: "removed" | "added"
|
||||||
|
callbackTime: number
|
||||||
|
callbackFrame: number
|
||||||
|
routeConnectedInCallback: boolean
|
||||||
|
nativeOffsetInCallback: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot = {
|
||||||
|
mode: {
|
||||||
|
resource: "baseline" | "guard"
|
||||||
|
reconnect: "baseline" | "candidate"
|
||||||
|
}
|
||||||
|
operation: {
|
||||||
|
sequence: number
|
||||||
|
phase: string
|
||||||
|
time: number
|
||||||
|
frame: number
|
||||||
|
}
|
||||||
|
resourceState: string
|
||||||
|
routeConnected: boolean
|
||||||
|
viewportConnected: boolean
|
||||||
|
viewportOwnedByRoute: boolean
|
||||||
|
sameRoute: boolean
|
||||||
|
sameViewport: boolean
|
||||||
|
sameSurface: boolean
|
||||||
|
sameMountedRows: boolean
|
||||||
|
nativeOffset: number
|
||||||
|
coreOffset: number
|
||||||
|
rangeStart: number
|
||||||
|
rangeEnd: number
|
||||||
|
indexes: number[]
|
||||||
|
domIndexes: number[]
|
||||||
|
logicalSurfaceHeight: number
|
||||||
|
renderedSurfaceHeight: number
|
||||||
|
viewportClientHeight: number
|
||||||
|
viewportScrollHeight: number
|
||||||
|
visibleRows: number
|
||||||
|
minimumRowTop: number
|
||||||
|
domScrollEvents: number
|
||||||
|
lastScrollTrusted: boolean
|
||||||
|
coreOffsetCallbackCalls: number
|
||||||
|
offsetCallbackSources: "observer"[]
|
||||||
|
rectObserverCallbacks: number
|
||||||
|
ignoredDetachedZeroRects: number
|
||||||
|
syntheticScrollDispatches: number
|
||||||
|
mutationEvents: MutationEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
timelineSuspense: {
|
||||||
|
prepare: () => Promise<Snapshot>
|
||||||
|
trigger: () => void
|
||||||
|
resolve: () => void
|
||||||
|
frames: (count?: number) => Promise<void>
|
||||||
|
snapshot: () => Snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [refresh, setRefresh] = createSignal(false)
|
||||||
|
let resolveResource: (() => void) | undefined
|
||||||
|
const [resource] = createResource(
|
||||||
|
refresh,
|
||||||
|
(version) =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveResource = () => resolve(`settled-${version}`)
|
||||||
|
}),
|
||||||
|
{ initialValue: "settled" },
|
||||||
|
)
|
||||||
|
|
||||||
|
function Route() {
|
||||||
|
let route: HTMLElement | undefined
|
||||||
|
let viewport: HTMLDivElement | undefined
|
||||||
|
let surface: HTMLDivElement | undefined
|
||||||
|
let initialRoute: HTMLElement | undefined
|
||||||
|
let initialViewport: HTMLDivElement | undefined
|
||||||
|
let initialSurface: HTMLDivElement | undefined
|
||||||
|
let initialRows: HTMLElement[] = []
|
||||||
|
let phase = "mounting"
|
||||||
|
let browserFrame = 0
|
||||||
|
let snapshotSequence = 0
|
||||||
|
let domScrollEvents = 0
|
||||||
|
let lastScrollTrusted = false
|
||||||
|
let coreOffsetCallbackCalls = 0
|
||||||
|
let rectObserverCallbacks = 0
|
||||||
|
let ignoredDetachedZeroRects = 0
|
||||||
|
const offsetCallbackSources: "observer"[] = []
|
||||||
|
const mutationEvents: MutationEvent[] = []
|
||||||
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||||
|
count: rowCount,
|
||||||
|
getScrollElement: () => viewport ?? null,
|
||||||
|
estimateSize: () => rowHeight,
|
||||||
|
initialRect: { width: 900, height: 600 },
|
||||||
|
overscan: 2,
|
||||||
|
observeElementRect: (instance, callback) =>
|
||||||
|
observeElementRect(instance, (rect) => {
|
||||||
|
rectObserverCallbacks++
|
||||||
|
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
||||||
|
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
||||||
|
ignoredDetachedZeroRects++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(rect)
|
||||||
|
}),
|
||||||
|
observeElementOffset: (instance, callback) => {
|
||||||
|
const deliver = (offset: number, isScrolling: boolean) => {
|
||||||
|
coreOffsetCallbackCalls++
|
||||||
|
offsetCallbackSources.push("observer")
|
||||||
|
callback(offset, isScrolling)
|
||||||
|
}
|
||||||
|
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
||||||
|
return observeElementOffset(instance, deliver)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const frames = async (count = 2) => {
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
||||||
|
const snapshot = (): Snapshot => {
|
||||||
|
const rows = mountedRows()
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
const visibleRows =
|
||||||
|
viewport?.isConnected && view
|
||||||
|
? rows.filter((row) => {
|
||||||
|
const rect = row.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
}).length
|
||||||
|
: 0
|
||||||
|
return {
|
||||||
|
mode: { resource: resourceMode, reconnect: reconnectMode },
|
||||||
|
operation: {
|
||||||
|
sequence: ++snapshotSequence,
|
||||||
|
phase,
|
||||||
|
time: performance.now(),
|
||||||
|
frame: browserFrame,
|
||||||
|
},
|
||||||
|
resourceState: resource.state,
|
||||||
|
routeConnected: route?.isConnected ?? false,
|
||||||
|
viewportConnected: viewport?.isConnected ?? false,
|
||||||
|
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
||||||
|
sameRoute: route === initialRoute,
|
||||||
|
sameViewport: viewport === initialViewport,
|
||||||
|
sameSurface: surface === initialSurface,
|
||||||
|
sameMountedRows:
|
||||||
|
initialRows.length > 0 &&
|
||||||
|
initialRows.length === rows.length &&
|
||||||
|
initialRows.every((row, index) => row === rows[index]),
|
||||||
|
nativeOffset: viewport?.scrollTop ?? -1,
|
||||||
|
coreOffset: virtualizer.scrollOffset ?? -1,
|
||||||
|
rangeStart: virtualizer.range?.startIndex ?? -1,
|
||||||
|
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
||||||
|
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
||||||
|
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
||||||
|
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
||||||
|
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
||||||
|
viewportClientHeight: viewport?.clientHeight ?? -1,
|
||||||
|
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
||||||
|
visibleRows,
|
||||||
|
minimumRowTop:
|
||||||
|
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
||||||
|
domScrollEvents,
|
||||||
|
lastScrollTrusted,
|
||||||
|
coreOffsetCallbackCalls,
|
||||||
|
offsetCallbackSources: [...offsetCallbackSources],
|
||||||
|
rectObserverCallbacks,
|
||||||
|
ignoredDetachedZeroRects,
|
||||||
|
syntheticScrollDispatches: 0,
|
||||||
|
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
||||||
|
const routeRoot = route.parentElement
|
||||||
|
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
||||||
|
initialRoute = route
|
||||||
|
initialViewport = viewport
|
||||||
|
initialSurface = surface
|
||||||
|
viewport.addEventListener("scroll", (event) => {
|
||||||
|
domScrollEvents++
|
||||||
|
lastScrollTrusted = event.isTrusted
|
||||||
|
})
|
||||||
|
const countFrames = () => {
|
||||||
|
browserFrame++
|
||||||
|
requestAnimationFrame(countFrames)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(countFrames)
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
const callbackTime = performance.now()
|
||||||
|
records.forEach((record) => {
|
||||||
|
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
||||||
|
if (node !== route) return
|
||||||
|
phase = "detached"
|
||||||
|
mutationEvents.push({
|
||||||
|
kind: "removed",
|
||||||
|
callbackTime,
|
||||||
|
callbackFrame: browserFrame,
|
||||||
|
routeConnectedInCallback: route.isConnected,
|
||||||
|
nativeOffsetInCallback: viewport.scrollTop,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
||||||
|
if (node !== route) return
|
||||||
|
phase = "reinserted"
|
||||||
|
mutationEvents.push({
|
||||||
|
kind: "added",
|
||||||
|
callbackTime,
|
||||||
|
callbackFrame: browserFrame,
|
||||||
|
routeConnectedInCallback: route.isConnected,
|
||||||
|
nativeOffsetInCallback: viewport.scrollTop,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).observe(routeRoot, { childList: true })
|
||||||
|
window.timelineSuspense = {
|
||||||
|
prepare: async () => {
|
||||||
|
phase = "preparing"
|
||||||
|
await frames(2)
|
||||||
|
viewport.scrollTop = viewport.scrollHeight
|
||||||
|
await frames(3)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
await frames(2)
|
||||||
|
initialRows = mountedRows()
|
||||||
|
phase = "prepared"
|
||||||
|
return snapshot()
|
||||||
|
},
|
||||||
|
trigger: () => {
|
||||||
|
phase = "triggering"
|
||||||
|
setRefresh(true)
|
||||||
|
},
|
||||||
|
resolve: () => {
|
||||||
|
if (!resolveResource) throw new Error("Resource is not pending")
|
||||||
|
phase = "resolving"
|
||||||
|
resolveResource()
|
||||||
|
},
|
||||||
|
frames,
|
||||||
|
snapshot,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
||||||
|
<span aria-hidden="true" style={{ display: "none" }}>
|
||||||
|
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref={viewport}
|
||||||
|
data-viewport
|
||||||
|
style={{
|
||||||
|
height: "600px",
|
||||||
|
overflow: "auto",
|
||||||
|
"overflow-anchor": "none",
|
||||||
|
position: "relative",
|
||||||
|
background: "#202020",
|
||||||
|
outline: "1px solid #3f3f46",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={surface}
|
||||||
|
data-surface
|
||||||
|
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
||||||
|
>
|
||||||
|
<For each={virtualizer.getVirtualItems()}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
data-row-index={item.index}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "0",
|
||||||
|
left: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: `${item.size}px`,
|
||||||
|
transform: `translateY(${item.start}px)`,
|
||||||
|
padding: "10px 14px",
|
||||||
|
border: "0 solid #333",
|
||||||
|
"border-bottom-width": "1px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
logical row {item.index}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Suspense>
|
||||||
|
<Route />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(() => <App />, document.getElementById("root")!)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test"
|
||||||
|
|
||||||
|
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: ".",
|
||||||
|
testMatch: "timeline-suspense.repro.ts",
|
||||||
|
outputDir: "../../test-results/timeline-suspense",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: "line",
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
||||||
|
cwd: import.meta.dirname,
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
page.on("pageerror", (error) => console.error(error))
|
||||||
|
await page.goto("/")
|
||||||
|
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
||||||
|
await page.goto("/?reconnect=candidate")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
||||||
|
const before = await prepare(page)
|
||||||
|
await triggerBaselineSuspension(page)
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.nativeOffset).toBe(0)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.indexes).toEqual(before.indexes)
|
||||||
|
expect(pending.sameRoute).toBe(true)
|
||||||
|
expect(pending.sameViewport).toBe(true)
|
||||||
|
expect(pending.sameSurface).toBe(true)
|
||||||
|
expect(pending.sameMountedRows).toBe(true)
|
||||||
|
|
||||||
|
await resolveSuspension(page)
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||||
|
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(after.sameRoute).toBe(true)
|
||||||
|
expect(after.sameViewport).toBe(true)
|
||||||
|
expect(after.sameSurface).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(0)
|
||||||
|
expect(after.coreOffset).toBe(0)
|
||||||
|
expect(after.rangeStart).toBeLessThan(10)
|
||||||
|
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
||||||
|
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
||||||
|
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
||||||
|
expect(after.syntheticScrollDispatches).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const before = await prepare(page)
|
||||||
|
const beforeRows = before.domIndexes
|
||||||
|
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
||||||
|
expect(before.logicalSurfaceHeight).toBe(80_000)
|
||||||
|
expect(before.renderedSurfaceHeight).toBe(80_000)
|
||||||
|
expect(before.viewportClientHeight).toBe(600)
|
||||||
|
expect(before.viewportScrollHeight).toBe(80_000)
|
||||||
|
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||||
|
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||||
|
expect(before.visibleRows).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
await triggerBaselineSuspension(page)
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.resourceState).toBe("refreshing")
|
||||||
|
expect(pending.routeConnected).toBe(false)
|
||||||
|
expect(pending.viewportConnected).toBe(false)
|
||||||
|
expect(pending.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(pending.nativeOffset).toBe(0)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.rangeStart).toBe(before.rangeStart)
|
||||||
|
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
||||||
|
expect(pending.indexes).toEqual(before.indexes)
|
||||||
|
expect(pending.domIndexes).toEqual(beforeRows)
|
||||||
|
expect(pending.sameMountedRows).toBe(true)
|
||||||
|
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||||
|
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
||||||
|
expect(pending.mutationEvents).toHaveLength(1)
|
||||||
|
expect(pending.mutationEvents[0]).toMatchObject({
|
||||||
|
kind: "removed",
|
||||||
|
routeConnectedInCallback: false,
|
||||||
|
nativeOffsetInCallback: 0,
|
||||||
|
})
|
||||||
|
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
||||||
|
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
||||||
|
|
||||||
|
const after = await resolveSuspension(page)
|
||||||
|
expect(after.resourceState).toBe("ready")
|
||||||
|
expect(after.routeConnected).toBe(true)
|
||||||
|
expect(after.viewportConnected).toBe(true)
|
||||||
|
expect(after.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(after.sameRoute).toBe(true)
|
||||||
|
expect(after.sameViewport).toBe(true)
|
||||||
|
expect(after.sameSurface).toBe(true)
|
||||||
|
expect(after.sameMountedRows).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(0)
|
||||||
|
expect(after.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(after.rangeStart).toBe(before.rangeStart)
|
||||||
|
expect(after.rangeEnd).toBe(before.rangeEnd)
|
||||||
|
expect(after.indexes).toEqual(before.indexes)
|
||||||
|
expect(after.domIndexes).toEqual(beforeRows)
|
||||||
|
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||||
|
expect(after.mutationEvents).toHaveLength(2)
|
||||||
|
expect(after.mutationEvents[1]).toMatchObject({
|
||||||
|
kind: "added",
|
||||||
|
routeConnectedInCallback: true,
|
||||||
|
nativeOffsetInCallback: 0,
|
||||||
|
})
|
||||||
|
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
||||||
|
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
||||||
|
expect(after.visibleRows).toBe(0)
|
||||||
|
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
||||||
|
expect(after.syntheticScrollDispatches).toBe(0)
|
||||||
|
|
||||||
|
await page.locator("[data-viewport]").hover()
|
||||||
|
await page.mouse.wheel(0, 80)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const value = window.timelineSuspense.snapshot()
|
||||||
|
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||||
|
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
||||||
|
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
||||||
|
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
||||||
|
expect(recovered.lastScrollTrusted).toBe(true)
|
||||||
|
expect(recovered.rangeStart).toBeLessThan(10)
|
||||||
|
expect(recovered.visibleRows).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
||||||
|
await page.goto("/?resource=guard")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
||||||
|
const before = await prepare(page)
|
||||||
|
|
||||||
|
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.routeConnected).toBe(true)
|
||||||
|
expect(pending.mutationEvents).toEqual([])
|
||||||
|
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.visibleRows).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const after = await resolveSuspension(page)
|
||||||
|
expect(after.routeConnected).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(before.nativeOffset)
|
||||||
|
expect(after.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(after.visibleRows).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function prepare(page: Page) {
|
||||||
|
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
||||||
|
expect(before.routeConnected).toBe(true)
|
||||||
|
expect(before.viewportConnected).toBe(true)
|
||||||
|
expect(before.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(before.sameMountedRows).toBe(true)
|
||||||
|
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||||
|
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||||
|
return before
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerBaselineSuspension(page: Page) {
|
||||||
|
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSuspension(page: Page) {
|
||||||
|
await page.evaluate(() => window.timelineSuspense.resolve())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
return page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(value: unknown) {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite"
|
||||||
|
import solid from "vite-plugin-solid"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: import.meta.dirname,
|
||||||
|
plugins: [solid()],
|
||||||
|
})
|
||||||
@@ -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" }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"./performance/timeline-stability/fixture.test.ts",
|
"./performance/timeline-stability/fixture.test.ts",
|
||||||
"./performance/timeline-stability/fixture.ts",
|
"./performance/timeline-stability/fixture.ts",
|
||||||
"./performance/unit/visual-stability.test.ts",
|
"./performance/unit/visual-stability.test.ts",
|
||||||
|
"./reproduction/timeline-suspense/**/*.ts",
|
||||||
|
"./reproduction/timeline-suspense/**/*.tsx",
|
||||||
|
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||||
"./regression/new-session-panel-corner.spec.ts",
|
"./regression/new-session-panel-corner.spec.ts",
|
||||||
"./regression/session-timeline-context-resize.spec.ts",
|
"./regression/session-timeline-context-resize.spec.ts",
|
||||||
"./utils/**/*.ts"
|
"./utils/**/*.ts"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 {
|
||||||
|
protocol?: "v1" | "v2"
|
||||||
provider: unknown
|
provider: unknown
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
@@ -54,9 +55,26 @@ 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 === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
return sse(
|
||||||
|
route,
|
||||||
|
path === "/api/event"
|
||||||
|
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||||
|
: [
|
||||||
|
...(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 === "/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")
|
||||||
@@ -84,10 +102,129 @@ 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: [] },
|
||||||
|
})
|
||||||
|
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
||||||
|
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 (
|
||||||
|
/^\/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])
|
||||||
@@ -110,6 +247,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
|
||||||
@@ -132,6 +287,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 : {}
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
parentID: session.parentID,
|
||||||
|
projectID: session.projectID ?? "project",
|
||||||
|
agent: session.agent ?? "build",
|
||||||
|
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||||
|
cost: session.cost ?? 0,
|
||||||
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: {
|
||||||
|
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||||
|
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||||
|
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||||
|
? { archived: session.time.archived }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
title: session.title ?? session.id,
|
||||||
|
location: {
|
||||||
|
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||||
|
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
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: [] },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status,
|
status,
|
||||||
@@ -152,3 +416,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,8 +174,11 @@ 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 (
|
||||||
return originalFetch(input, init)
|
url.origin !== server ||
|
||||||
|
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||||
|
)
|
||||||
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
@@ -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.17.18",
|
"version": "1.18.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -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.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)
|
||||||
|
|
||||||
|
|||||||
+30
-29
@@ -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"))
|
||||||
|
|
||||||
@@ -153,8 +164,7 @@ function LegacyTargetSessionRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
// Notification/Models + the visual Layout) for that server.
|
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
@@ -207,7 +217,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<DraftServerScopedProviders directory={directory}>
|
<ModelsProvider directory={directory}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
@@ -215,7 +225,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</DraftServerScopedProviders>
|
</ModelsProvider>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -309,24 +319,21 @@ function DesktopCommands() {
|
|||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
sessionID?: () => string | undefined
|
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<LayoutProvider>
|
||||||
<LayoutProvider>
|
{props.serverScoped}
|
||||||
{props.serverScoped}
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
</LayoutProvider>
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
)
|
)
|
||||||
@@ -342,14 +349,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
|
||||||
return (
|
|
||||||
<PermissionProvider directory={props.directory}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -559,13 +558,15 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<NotificationProvider>
|
<PermissionProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
</NotificationProvider>
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -1,17 +1,18 @@
|
|||||||
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 { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||||
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"
|
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -24,6 +25,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 +78,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 +105,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 +140,12 @@ 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.client.experimental.session.list({ roots: true, 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 +167,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 +203,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 +215,65 @@ 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?: GlobalSession[] }>
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
const state: {
|
let abort: AbortController | undefined
|
||||||
token: number
|
|
||||||
inflight: Promise<CommandPaletteEntry[]> | undefined
|
|
||||||
cached: CommandPaletteEntry[] | undefined
|
|
||||||
} = { token: 0, inflight: undefined, cached: undefined }
|
|
||||||
|
|
||||||
return (text: string) => {
|
onCleanup(() => abort?.abort())
|
||||||
if (!text.trim()) {
|
|
||||||
state.token += 1
|
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||||
state.inflight = undefined
|
const search = text.trim()
|
||||||
state.cached = undefined
|
if (!search) {
|
||||||
return [] as CommandPaletteEntry[]
|
abort?.abort()
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
if (state.cached) return state.cached
|
abort?.abort()
|
||||||
if (state.inflight) return state.inflight
|
const current = new AbortController()
|
||||||
|
abort = current
|
||||||
const current = state.token
|
await new Promise<void>((resolve) => {
|
||||||
const dirs = props.workspaces()
|
const timer = setTimeout(resolve, 100)
|
||||||
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
current.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
state.inflight = Promise.all(
|
() => {
|
||||||
dirs.map((directory) => {
|
clearTimeout(timer)
|
||||||
const description = props.label(directory)
|
resolve()
|
||||||
return props
|
},
|
||||||
.load(directory)
|
{ once: true },
|
||||||
.then((result) =>
|
)
|
||||||
(result.data ?? [])
|
})
|
||||||
.filter((session) => !!session?.id)
|
if (current.signal.aborted) return []
|
||||||
.map((session) => ({
|
const opened = props.opened()
|
||||||
id: session.id,
|
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||||
title: session.title ?? props.untitled(),
|
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||||
description,
|
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||||
directory,
|
return props
|
||||||
archived: session.time?.archived,
|
.load(search, current.signal)
|
||||||
updated: session.time?.updated,
|
.then((result) =>
|
||||||
})),
|
(result.data ?? [])
|
||||||
)
|
.filter((session) => !session.time.archived)
|
||||||
.catch(() => [] as SessionEntryInput[])
|
.map((session) => {
|
||||||
}),
|
const project =
|
||||||
)
|
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||||
.then((results) => {
|
return {
|
||||||
if (state.token !== current) return [] as CommandPaletteEntry[]
|
id: `session:${props.server}:${session.id}`,
|
||||||
const seen = new Set<string>()
|
type: "session" as const,
|
||||||
const next = results
|
title: session.title || props.untitled(),
|
||||||
.flat()
|
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
||||||
.filter((item) => {
|
category: props.category(),
|
||||||
const key = `${item.directory}:${item.id}`
|
directory: session.directory,
|
||||||
if (seen.has(key)) return false
|
sessionID: session.id,
|
||||||
seen.add(key)
|
server: props.server,
|
||||||
return true
|
project,
|
||||||
})
|
updated: session.time.updated,
|
||||||
.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?: {
|
||||||
@@ -107,8 +108,45 @@ 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 flex-row justify-start gap-1.5 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 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>
|
||||||
|
</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 +154,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 +181,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 +539,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,105 @@ 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.client.experimental.session.list({ roots: true, 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 +152,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 +179,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 +198,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 +210,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 +225,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 +251,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 +301,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" },
|
||||||
|
]),
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -27,6 +32,7 @@ import { Link } from "@/components/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"
|
||||||
|
|
||||||
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
|
|||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
const controller = props.controller ?? fallback
|
const controller = props.controller ?? fallback
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const reset = controller.back
|
const reset = controller.back
|
||||||
const back = { current: reset }
|
const back = { current: reset }
|
||||||
|
let focusHost: HTMLDivElement | undefined
|
||||||
|
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
||||||
const select = (provider?: string) => {
|
const select = (provider?: string) => {
|
||||||
back.current = reset
|
back.current = reset
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function Content() {
|
||||||
<Dialog
|
return (
|
||||||
class="h-full"
|
|
||||||
transition
|
|
||||||
title={
|
|
||||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => back.current()}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={controller.selected() === CUSTOM_ID}>
|
<Match when={controller.selected() === CUSTOM_ID}>
|
||||||
<CustomProviderForm />
|
<CustomProviderForm autofocus={!newLayout()} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
<ProviderPicker
|
||||||
|
directory={props.directory}
|
||||||
|
onSelect={select}
|
||||||
|
onPrepare={newLayout() ? holdFocus : undefined}
|
||||||
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Dialog>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={newLayout()}
|
||||||
|
fallback={
|
||||||
|
<Dialog
|
||||||
|
class="h-full"
|
||||||
|
transition
|
||||||
|
title={
|
||||||
|
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogV2
|
||||||
|
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
|
>
|
||||||
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
|
<Show
|
||||||
|
when={controller.selected()}
|
||||||
|
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="small" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
||||||
|
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogV2>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
function ProviderPicker(props: {
|
||||||
|
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 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")
|
||||||
@@ -163,6 +221,171 @@ 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 serverSync = useServerSync()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
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?.()
|
||||||
|
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
||||||
|
props.onSelect(provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.connecting) return
|
||||||
|
setStore("connecting", provider)
|
||||||
|
void serverSDK()
|
||||||
|
.client.provider.auth()
|
||||||
|
.then((response) => {
|
||||||
|
serverSync().set("provider_auth", response.data ?? {})
|
||||||
|
props.onSelect(provider)
|
||||||
|
})
|
||||||
|
.catch(() => 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>
|
||||||
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
|
|||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||||
|
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
||||||
|
const directMethod =
|
||||||
|
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: directMethod as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: (directMethod === undefined ? "pending" : undefined) as
|
||||||
|
| undefined
|
||||||
|
| "pending"
|
||||||
|
| "complete"
|
||||||
|
| "error"
|
||||||
|
| "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
|
|||||||
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 === "api" ? "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
|
||||||
@@ -519,6 +762,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">
|
||||||
@@ -552,11 +826,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()
|
||||||
|
|
||||||
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
|
|||||||
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")}
|
||||||
|
<Link
|
||||||
|
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")}
|
||||||
|
</Link>
|
||||||
|
{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"
|
||||||
|
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">
|
||||||
|
{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>
|
||||||
@@ -605,7 +938,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 +958,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()
|
||||||
|
|
||||||
@@ -657,6 +998,46 @@ 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")}
|
||||||
|
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
|
</Link>
|
||||||
|
{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">
|
||||||
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
|
|||||||
</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")}
|
||||||
@@ -738,10 +1120,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 +1141,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">
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -192,7 +192,7 @@ export function CustomProviderForm() {
|
|||||||
|
|
||||||
<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")}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createSignal, onMount } from "solid-js"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
"MiMo V2.5 Free",
|
||||||
|
"Nemotron 3 Ultra Free",
|
||||||
|
"Deepseek V4 Flash Free",
|
||||||
|
"North Mini Code Free",
|
||||||
|
"Hy3 Free",
|
||||||
|
"Big Pickle",
|
||||||
|
]
|
||||||
|
|
||||||
|
function SelectModelWithoutProviders() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const models = names.map((name, index) => ({
|
||||||
|
id: name.toLowerCase().replaceAll(" ", "-"),
|
||||||
|
name,
|
||||||
|
provider: { id: "opencode", name: "OpenCode" },
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 128_000 },
|
||||||
|
capabilities: {
|
||||||
|
reasoning: index !== 5,
|
||||||
|
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const [current, setCurrent] = createSignal(models[2])
|
||||||
|
const model = {
|
||||||
|
list: () => models,
|
||||||
|
current,
|
||||||
|
set(value) {
|
||||||
|
setCurrent(models.find((item) => item.id === value?.modelID))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open select model dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Select Model",
|
||||||
|
id: "app-dialog-select-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithoutProviders = {
|
||||||
|
render: () => <SelectModelWithoutProviders />,
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||||
|
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||||
|
|
||||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const model = props.model ?? local.model
|
const model = props.model ?? local.model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const theme = useTheme()
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
const providers = useProviders(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||||
|
const freeModels = createMemo(() => model.list().filter(isFree))
|
||||||
|
|
||||||
const openProviders = (provider?: string) => {
|
const openProviders = (provider?: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
@@ -62,111 +66,109 @@ 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 class="flex w-full flex-col items-start pb-3">
|
||||||
<div ref={listEl} class="flex min-h-full flex-col">
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<For each={model.list()}>
|
|
||||||
{(item) => (
|
|
||||||
<TooltipV2
|
|
||||||
class="w-full"
|
|
||||||
placement="right-start"
|
|
||||||
gutter={6}
|
|
||||||
openDelay={0}
|
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => selectModel(item)}
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{item.name}</span>
|
|
||||||
<Show when={isFree(item)}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.latest}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={currentKey() === modelKey(item)}>
|
|
||||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</TooltipV2>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
|
<For each={freeModels()}>
|
||||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
{(item) => (
|
||||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
<TooltipV2
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
class="w-full"
|
||||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
placement="right-start"
|
||||||
{language.t("dialog.model.unpaid.addMore.title")}
|
gutter={6}
|
||||||
</div>
|
openDelay={0}
|
||||||
</div>
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
<div class="flex w-full flex-col">
|
value={
|
||||||
<For
|
<ModelTooltip
|
||||||
each={[...providers.popular()].sort((a, b) => {
|
model={{ ...item, name: displayModelName(item.name) }}
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
latest={item.latest}
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
free={isFree(item)}
|
||||||
}
|
v2
|
||||||
return a.name.localeCompare(b.name)
|
/>
|
||||||
})}
|
}
|
||||||
>
|
>
|
||||||
{(provider) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => openProviders(provider.id)}
|
|
||||||
>
|
|
||||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
<span class="min-w-0 truncate">{provider.name}</span>
|
|
||||||
<Show when={provider.id === "opencode"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.opencode.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === "opencode-go"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === "anthropic"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.anthropic.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
onClick={() => openProviders()}
|
onClick={() => selectModel(item)}
|
||||||
>
|
>
|
||||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||||
<Icon name="dot-grid" size="small" />
|
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||||
</span>
|
<Show when={item.latest}>
|
||||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||||
{language.t("dialog.provider.viewAll")}
|
</Show>
|
||||||
</span>
|
<Show when={currentKey() === modelKey(item)}>
|
||||||
|
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
||||||
|
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
||||||
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.model.unpaid.addMore.title")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
|
<For
|
||||||
|
each={[...providers.popular()]
|
||||||
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
|
>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||||
|
classList={{
|
||||||
|
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||||
|
theme.mode() !== "dark",
|
||||||
|
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
||||||
|
}}
|
||||||
|
onClick={() => openProviders(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
<span class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
|
<span class="truncate font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t(
|
||||||
|
provider.id === "opencode"
|
||||||
|
? "dialog.provider.opencode.tagline"
|
||||||
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
onClick={() => openProviders()}
|
||||||
|
>
|
||||||
|
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogV2>
|
</DialogV2>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,77 +1,35 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
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 { createSignal, Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import homeImage from "@/assets/help/home.png"
|
||||||
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
const helpIcon = (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
data-slot="icon-svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
|
||||||
fill="var(--v2-icon-icon-base)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const triggerClass =
|
|
||||||
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
// TODO: wire to changelog / seen-state when available
|
||||||
const showPopover = () => true
|
const showPopover = () => true
|
||||||
|
|
||||||
export function HelpButton() {
|
|
||||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
|
||||||
|
|
||||||
const platform = usePlatform()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href="https://opencode.ai"
|
|
||||||
aria-label="Open the OpenCode website"
|
|
||||||
data-component="icon-button-v2"
|
|
||||||
data-size="large"
|
|
||||||
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
platform.openLink(event.currentTarget.href)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{helpIcon}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
const settings = useSettings()
|
||||||
|
const platform = usePlatform()
|
||||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
|
||||||
// setState({ dismissed: false }) // for testing
|
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
|
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
<Show when={!state.dismissed}>
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. A faster, more intuitive way to work."
|
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss Tabs information"
|
aria-label="Dismiss Tabs information"
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||||
onClick={() => setState("dismissed", true)}
|
onClick={settings.general.dismissTabsToast}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -88,7 +46,7 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setState("dismissed", true)
|
settings.general.dismissTabsToast()
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -107,36 +65,82 @@ export function TabsInfoPopup() {
|
|||||||
Introducing Tabs
|
Introducing Tabs
|
||||||
</p>
|
</p>
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||||
A faster, more intuitive way to work.
|
Organize your work and active sessions with tabs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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={
|
||||||
<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">
|
windows()
|
||||||
June 16
|
? {
|
||||||
</p>
|
inset: "0 0 0 auto",
|
||||||
|
"max-height": "100vh",
|
||||||
|
"max-width": "100vw",
|
||||||
|
"border-radius": "0",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={windows()}>
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
as={IconButtonV2}
|
as={IconButtonV2}
|
||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="neutral"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
icon={<IconV2 name="xmark-small" />}
|
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">
|
||||||
|
July 14
|
||||||
|
</p>
|
||||||
|
<Show when={!windows()}>
|
||||||
|
<DrawerClose
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
aria-label="Close"
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||||
Introducing Tabs Navigation.
|
Introducing Tabs
|
||||||
</p>
|
|
||||||
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
|
||||||
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
|
||||||
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
|
||||||
sessions.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
<p>OpenCode Desktop is now built around tabs.</p>
|
||||||
|
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
|
<p>
|
||||||
|
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||||
|
you're starting something new, and close it when you're done.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||||
|
memorable if you plan to keep them around.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||||
|
</p>
|
||||||
|
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
|
<p>When you reopen the app, your tabs are still open.</p>
|
||||||
|
<p>
|
||||||
|
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||||
|
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||||
|
will become permanent in a few weeks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
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 { Prompt, 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
|
||||||
|
edit?: PromptInputProps["edit"]
|
||||||
|
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
||||||
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
useCommands(props)
|
||||||
|
useEditHandler(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<PromptInputV2
|
||||||
|
controller={props.controller}
|
||||||
|
borderUnderlay={props.borderUnderlay}
|
||||||
|
class={props.class}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
||||||
|
const prompt = usePrompt()
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
props.controller.dispatch({ type: "mode.normal" })
|
||||||
|
props.controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
props.controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCommands = (props: PromptInputV2ComposerProps) => {
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: props.controller.state.mode !== "normal",
|
||||||
|
onSelect: () => props.controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: props.controller.state.mode === "shell",
|
||||||
|
onSelect: () => props.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: props.controller.state.mode === "normal",
|
||||||
|
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
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.dataUrl} 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"),
|
||||||
|
}),
|
||||||
|
onError: (error) =>
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
readClipboardImage: platform.readClipboardImage,
|
||||||
|
getPathForFile: platform.getPathForFile,
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
placeholder: designPlaceholder,
|
||||||
|
agent:
|
||||||
|
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: props.controls.agents.select,
|
||||||
|
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 })
|
||||||
|
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"
|
||||||
|
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}
|
||||||
|
triggerAs={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",
|
||||||
|
}}
|
||||||
|
onClose={props.onClose}
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ModelSelectorPopoverV2>
|
||||||
|
</Show>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openComment(
|
||||||
|
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||||
|
props: PromptInputV2ControllerProps,
|
||||||
|
sync: ReturnType<typeof useSync>,
|
||||||
|
layout: ReturnType<typeof useLayout>,
|
||||||
|
files: ReturnType<typeof useFile>,
|
||||||
|
comments: ReturnType<typeof useComments>,
|
||||||
|
) {
|
||||||
|
if (!item.commentID) return
|
||||||
|
const focus = { file: item.path, id: item.commentID }
|
||||||
|
comments.setActive(focus)
|
||||||
|
const queueFocus = (attempts = 6) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
comments.setFocus({ ...focus })
|
||||||
|
if (attempts <= 0) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const current = comments.focus()
|
||||||
|
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||||
|
const review =
|
||||||
|
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||||
|
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||||
|
if (review) {
|
||||||
|
layout.fileTree.setTab("changes")
|
||||||
|
props.controls.session.tabs.setActive("review")
|
||||||
|
queueFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layout.fileTree.setTab("all")
|
||||||
|
const tab = files.tab(item.path)
|
||||||
|
void props.controls.session.tabs.open(tab)
|
||||||
|
props.controls.session.tabs.setActive(tab)
|
||||||
|
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||||
|
}
|
||||||
@@ -30,8 +30,16 @@ function PromptInputExample() {
|
|||||||
activeTab: undefined as string | undefined,
|
activeTab: undefined as string | undefined,
|
||||||
reviewOpen: false,
|
reviewOpen: false,
|
||||||
})
|
})
|
||||||
|
const storyModel = {
|
||||||
|
id: "claude-3-7-sonnet",
|
||||||
|
name: "Claude 3.7 Sonnet",
|
||||||
|
provider: { id: "anthropic", name: "Anthropic" },
|
||||||
|
}
|
||||||
const model = {
|
const model = {
|
||||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
current: () => storyModel,
|
||||||
|
list: () => [storyModel],
|
||||||
|
visible: () => true,
|
||||||
|
set: () => {},
|
||||||
variant: {
|
variant: {
|
||||||
list: () => ["fast", "thinking"],
|
list: () => ["fast", "thinking"],
|
||||||
current: () => controls.variant,
|
current: () => controls.variant,
|
||||||
@@ -67,7 +75,6 @@ function PromptInputExample() {
|
|||||||
open: () => setControls("reviewOpen", true),
|
open: () => setControls("reviewOpen", true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
}
|
||||||
const addReviewComment = () => {
|
const addReviewComment = () => {
|
||||||
const comment = controls.comments + 1
|
const comment = controls.comments + 1
|
||||||
@@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
|
|||||||
},
|
},
|
||||||
reviewPanel: { opened: () => false, open: () => {} },
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
},
|
},
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
}
|
||||||
const state = {
|
const state = {
|
||||||
blocked: () => false,
|
blocked: () => false,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
|
|||||||
getPathForFile?: (file: File) => string
|
getPathForFile?: (file: File) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
export type PromptAttachmentsInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
|
|||||||
@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
const requestParts: PromptRequestPart[] = [
|
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||||
{
|
? [
|
||||||
id: Identifier.ascending("part"),
|
{
|
||||||
type: "text",
|
id: Identifier.ascending("part"),
|
||||||
text: input.text,
|
type: "text",
|
||||||
},
|
text: input.text,
|
||||||
]
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||||
const path = absolute(input.sessionDirectory, attachment.path)
|
const path = absolute(input.sessionDirectory, attachment.path)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { useLocal } from "@/context/local"
|
||||||
|
import type { Prompt, usePrompt } from "@/context/prompt"
|
||||||
|
import type { PromptInputHistory } from "./history-store"
|
||||||
|
import type { FollowupDraft } from "./submit"
|
||||||
|
|
||||||
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
|
export type PromptInputSubmission = {
|
||||||
|
abort: () => Promise<void> | void
|
||||||
|
handleSubmit: (event: Event) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputControls = {
|
||||||
|
agents: {
|
||||||
|
available: { name: string; hidden?: boolean; mode: string }[]
|
||||||
|
options: string[]
|
||||||
|
current: string
|
||||||
|
loading: boolean
|
||||||
|
visible: boolean
|
||||||
|
select: (name: string | undefined) => void
|
||||||
|
}
|
||||||
|
model: {
|
||||||
|
selection: ReturnType<typeof useLocal>["model"]
|
||||||
|
paid: boolean
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
session: {
|
||||||
|
id?: string
|
||||||
|
tabs: {
|
||||||
|
active: () => string | undefined
|
||||||
|
all: () => string[]
|
||||||
|
open: (tab: string) => void | Promise<void>
|
||||||
|
setActive: (tab: string) => void
|
||||||
|
}
|
||||||
|
reviewPanel: {
|
||||||
|
opened: () => boolean
|
||||||
|
open: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptInputProps {
|
||||||
|
class?: string
|
||||||
|
state?: PromptInputState
|
||||||
|
history?: PromptInputHistory
|
||||||
|
submission?: PromptInputSubmission
|
||||||
|
controls: PromptInputControls
|
||||||
|
ref?: (el: HTMLDivElement) => void
|
||||||
|
newSessionWorktree?: string
|
||||||
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||||
|
onEditLoaded?: () => void
|
||||||
|
shouldQueue?: () => boolean
|
||||||
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
|
onAbort?: () => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { 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] = 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)
|
||||||
|
}
|
||||||
@@ -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,12 +1,13 @@
|
|||||||
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 enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -18,6 +19,7 @@ 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 sessionDirectories: Record<string, string> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
@@ -27,9 +29,17 @@ let params: { id?: string } = {}
|
|||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
|
let permissionServer = "server-a"
|
||||||
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const 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,6 +66,7 @@ const clientFor = (directory: string) => {
|
|||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
|
await createSessionGate
|
||||||
createdSessions.push(directory)
|
createdSessions.push(directory)
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -79,6 +90,27 @@ const clientFor = (directory: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
session: {
|
||||||
|
async create(input: { location: { directory: string } }) {
|
||||||
|
await createSessionGate
|
||||||
|
createdSessions.push(input.location.directory)
|
||||||
|
const session = {
|
||||||
|
id: `session-${createdSessions.length}`,
|
||||||
|
title: `New session ${createdSessions.length}`,
|
||||||
|
}
|
||||||
|
sessionDirectories[session.id] = input.location.directory
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
async shell(input: { sessionID: string }) {
|
||||||
|
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
|
||||||
|
},
|
||||||
|
async prompt() {},
|
||||||
|
async command() {},
|
||||||
|
async interrupt() {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const rootClient = clientFor("/repo/main")
|
const rootClient = clientFor("/repo/main")
|
||||||
|
|
||||||
@@ -122,13 +154,14 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => ({
|
mock.module("@/context/permission", () => {
|
||||||
usePermission: () => ({
|
const state = (server: string) => ({
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
enabledAutoAccept.push({ sessionID, directory })
|
enabledAutoAccept.push({ server, sessionID, directory })
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
}))
|
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||||
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
useServer: () => ({ key: "server-key" }),
|
useServer: () => ({ key: "server-key" }),
|
||||||
@@ -160,6 +193,7 @@ beforeAll(async () => {
|
|||||||
const sdk = {
|
const sdk = {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
|
api,
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
@@ -251,7 +285,10 @@ beforeEach(() => {
|
|||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
|
permissionServer = "server-a"
|
||||||
|
createSessionGate = undefined
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
|
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
@@ -318,7 +355,40 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps auto-accept bound to the submission server", async () => {
|
||||||
|
let release = () => {}
|
||||||
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => true,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
permissionServer = "server-b"
|
||||||
|
release()
|
||||||
|
await result
|
||||||
|
|
||||||
|
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
test("promotes drafts using the selected project's server", async () => {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -39,7 +41,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,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.command({
|
const messageID = Identifier.ascending("message")
|
||||||
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
arguments: tail.join(" "),
|
arguments: tail.join(" "),
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: {
|
||||||
variant: input.draft.variant,
|
id: input.draft.model.modelID,
|
||||||
parts: images.map((attachment) => ({
|
providerID: input.draft.model.providerID,
|
||||||
id: Identifier.ascending("part"),
|
variant: input.draft.variant,
|
||||||
type: "file" as const,
|
},
|
||||||
mime: attachment.mime,
|
files: images.map((attachment) => ({
|
||||||
url: attachment.dataUrl,
|
uri: attachment.dataUrl,
|
||||||
filename: attachment.filename,
|
name: attachment.filename,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -152,13 +156,36 @@ 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,
|
||||||
|
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 +237,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 +263,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(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +341,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk().directory
|
const projectDirectory = sdk().directory
|
||||||
|
const permissionState = permission.currentServerState()
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
@@ -363,9 +390,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"),
|
||||||
@@ -378,7 +409,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
session = created
|
session = created
|
||||||
await startTransition(() => {
|
await startTransition(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
local.session.promote(sessionDirectory, session.id, {
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
@@ -449,12 +480,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({
|
||||||
@@ -472,23 +505,23 @@ 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: images.map((attachment) => ({
|
||||||
parts: images.map((attachment) => ({
|
uri: attachment.dataUrl,
|
||||||
id: Identifier.ascending("part"),
|
name: attachment.filename,
|
||||||
type: "file" as const,
|
|
||||||
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")),
|
||||||
@@ -572,7 +605,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 }))
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
splitProps,
|
||||||
|
type Accessor,
|
||||||
|
type ComponentProps,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -46,7 +55,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()
|
||||||
@@ -56,6 +65,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
|
||||||
@@ -91,8 +101,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)
|
||||||
}
|
}
|
||||||
@@ -115,6 +125,7 @@ export function createPromptProjectController(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selected,
|
selected,
|
||||||
|
empty: () => input.controls().available.length === 0,
|
||||||
projects,
|
projects,
|
||||||
servers,
|
servers,
|
||||||
projectKey,
|
projectKey,
|
||||||
@@ -184,9 +195,28 @@ export function PromptProjectSelector(props: {
|
|||||||
controller: PromptProjectController
|
controller: PromptProjectController
|
||||||
placement?: "bottom" | "bottom-start"
|
placement?: "bottom" | "bottom-start"
|
||||||
}) {
|
}) {
|
||||||
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
let triggerFrame: number | undefined
|
||||||
let restoreTrigger = true
|
let restoreTrigger = true
|
||||||
|
|
||||||
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
|
const ready = () => {
|
||||||
|
if (!element.isConnected) {
|
||||||
|
triggerFrame = requestAnimationFrame(ready)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
triggerFrame = undefined
|
||||||
|
setTriggerReady(true)
|
||||||
|
}
|
||||||
|
ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
|
||||||
|
})
|
||||||
|
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
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())}"]`)
|
||||||
@@ -257,13 +287,13 @@ export function PromptProjectSelector(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={props.controller.open()}
|
open={triggerReady() && props.controller.open()}
|
||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
|||||||
@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
</MenuV2>
|
</MenuV2>
|
||||||
<Show when={props.branch}>
|
<PromptGitStatus branch={props.branch} />
|
||||||
{(branch) => (
|
|
||||||
<>
|
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
value={branch()}
|
|
||||||
class="min-w-0 max-w-[220px]"
|
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
|
||||||
>
|
|
||||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
<span class="min-w-0 truncate">{branch()}</span>
|
|
||||||
</div>
|
|
||||||
</TooltipV2>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const label = () => {
|
||||||
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
|
return props.branch
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={label()}>
|
||||||
|
{(value) => (
|
||||||
|
<>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
value={value()}
|
||||||
|
class="min-w-0 max-w-[220px]"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const key = ServerConnection.key(props.server)
|
const key = ServerConnection.key(props.server)
|
||||||
const builtin = ServerConnection.builtin(props.server)
|
return (
|
||||||
const isDefault = () => props.controller.defaultKey() === key
|
<ServerRowMenuView
|
||||||
|
server={props.server}
|
||||||
|
labels={serverMenuLabels(language)}
|
||||||
|
canDefault={props.controller.canDefault()}
|
||||||
|
isDefault={props.controller.defaultKey() === key}
|
||||||
|
onEdit={props.onEdit}
|
||||||
|
onSetDefault={() => props.controller.setDefault(key)}
|
||||||
|
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||||
|
onRemove={() => props.controller.handleRemove(key)}
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||||
|
return {
|
||||||
|
more: language.t("common.moreOptions"),
|
||||||
|
server: language.t("settings.section.server"),
|
||||||
|
edit: language.t("dialog.server.menu.edit"),
|
||||||
|
default: language.t("dialog.server.menu.default"),
|
||||||
|
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||||
|
delete: language.t("dialog.server.menu.delete"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerRowMenuView: Component<{
|
||||||
|
server: ServerConnection.Any
|
||||||
|
labels: ReturnType<typeof serverMenuLabels>
|
||||||
|
canDefault: boolean
|
||||||
|
isDefault: boolean
|
||||||
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
|
onSetDefault: () => void
|
||||||
|
onRemoveDefault: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}> = (props) => {
|
||||||
|
const builtin = () => ServerConnection.builtin(props.server)
|
||||||
|
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||||
return (
|
return (
|
||||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
|
|||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconV2 name="outline-dots" />}
|
icon={<IconV2 name="outline-dots" />}
|
||||||
aria-label={language.t("common.moreOptions")}
|
aria-label={props.labels.more}
|
||||||
/>
|
/>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content>
|
<MenuV2.Content>
|
||||||
<MenuV2.Group>
|
<MenuV2.Group>
|
||||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||||
<MenuV2.Item
|
<MenuV2.Item
|
||||||
disabled={builtin || props.server.type !== "http"}
|
disabled={builtin() || !httpServer()}
|
||||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
onSelect={() => {
|
||||||
|
const server = httpServer()
|
||||||
|
if (server) props.onEdit(server)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.menu.edit")}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
<Show when={props.canDefault && !props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && isDefault()}>
|
<Show when={props.canDefault && props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||||
{language.t("dialog.server.menu.delete")}
|
{props.labels.delete}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</MenuV2.Group>
|
</MenuV2.Group>
|
||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
|
|||||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
<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="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||||
<div class="mt-8">{props.children}</div>
|
<div class="mt-8">{props.children}</div>
|
||||||
</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)}
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
|
|||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (store.editing) return
|
if (store.editing) return
|
||||||
|
terminal.requestFocus(props.terminal.id)
|
||||||
terminal.open(props.terminal.id)
|
terminal.open(props.terminal.id)
|
||||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
focusTerminalById(props.terminal.id)
|
focusTerminalById(props.terminal.id)
|
||||||
|
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
|
||||||
|
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const edit = (e?: Event) => {
|
const edit = (e?: Event) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
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 { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
|
|||||||
triggerVariant: "settings" as const,
|
triggerVariant: "settings" as const,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const InterfaceSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{language.t("settings.general.row.newInterface.title")}
|
||||||
|
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={language.t("settings.general.row.newInterface.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.newLayoutDesigns()}
|
||||||
|
onChange={(checked) => {
|
||||||
|
settings.general.setNewLayoutDesigns(checked)
|
||||||
|
if (!checked) return
|
||||||
|
void import("@/components/settings-v2").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const InterfaceNoticeSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
|
>
|
||||||
|
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||||
|
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
|
</Button>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<SettingsList>
|
<SettingsList>
|
||||||
@@ -334,24 +379,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
|
||||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (!checked) return
|
|
||||||
void import("@/components/settings-v2").then((module) => {
|
|
||||||
dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -718,6 +745,14 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
<div class="flex flex-col gap-8 w-full">
|
||||||
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
<InterfaceNoticeSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|||||||
import { Link } from "../link"
|
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 "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const InterfaceSection = () => (
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={language.t("settings.general.row.newInterface.title")}
|
||||||
|
badge={language.t("settings.general.row.newInterface.badge")}
|
||||||
|
description={language.t("settings.general.row.newInterface.description")}
|
||||||
|
checked={settings.general.newLayoutDesigns()}
|
||||||
|
onChange={(checked) => {
|
||||||
|
settings.general.setNewLayoutDesigns(checked)
|
||||||
|
if (checked) return
|
||||||
|
void import("@/components/dialog-settings").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const InterfaceNoticeSection = () => (
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
|
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
|
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
|
||||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (checked) return
|
|
||||||
void import("@/components/dialog-settings").then((module) => {
|
|
||||||
dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||||
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
<InterfaceNoticeSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "New layout",
|
||||||
|
badge: "New",
|
||||||
|
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||||
|
noticeTitle: "You're now using new layout",
|
||||||
|
noticeDescription: "The previous layout is no longer available",
|
||||||
|
dismiss: "Dismiss",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Frame(props) {
|
||||||
|
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleExample(props) {
|
||||||
|
const [state, setState] = createStore({ checked: props.checked })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={copy.title}
|
||||||
|
badge={copy.badge}
|
||||||
|
description={copy.description}
|
||||||
|
checked={state.checked}
|
||||||
|
onChange={(checked) => setState("checked", checked)}
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeExample() {
|
||||||
|
const [state, setState] = createStore({ dismissed: false })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={copy.noticeTitle}
|
||||||
|
description={copy.noticeDescription}
|
||||||
|
dismiss={copy.dismiss}
|
||||||
|
onDismiss={() => setState("dismissed", true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Settings/Layout transition",
|
||||||
|
id: "app-settings-layout-transition",
|
||||||
|
component: LayoutTransitionToggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked={false} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutRetired = {
|
||||||
|
render: () => <NoticeExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates = {
|
||||||
|
render: () => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<ToggleExample checked />
|
||||||
|
<ToggleExample checked={false} />
|
||||||
|
<NoticeExample />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
|
||||||
|
export function LayoutTransitionToggle(props: {
|
||||||
|
title: string
|
||||||
|
badge: string
|
||||||
|
description: string
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="settings-v2-interface-feature">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{props.title}
|
||||||
|
<Tag variant="accent">{props.badge}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={props.description}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch checked={props.checked} onChange={props.onChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutRetirementNotice(props: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismiss: string
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2 title={props.title} description={props.description}>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||||
|
{props.dismiss}
|
||||||
|
</ButtonV2>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -90,6 +90,11 @@
|
|||||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-interface-feature [data-component="settings-v2-list"] {
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
box-shadow: var(--v2-elevation-raised);
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="settings-v2-row"] {
|
[data-component="settings-v2-row"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -128,9 +133,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="settings-v2-row-description"] {
|
[data-slot="settings-v2-row-description"] {
|
||||||
font-size: 11px;
|
margin-block: -3.5px;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +372,7 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-body.settings-v2-models {
|
.settings-v2-models {
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||||
|
|
||||||
|
describe("serverStatusDotClass", () => {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the critical token only after the server connection drops", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stays neutral before status is ready", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
|
||||||
|
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasNonBlockingServiceIssue", () => {
|
||||||
|
test("detects MCP failures that do not block chatting", () => {
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects LSP failures that do not block chatting", () => {
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
|
mcp: Array<McpServer["status"]["status"]>
|
||||||
|
lsp: Array<LspStatus["status"]>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||||
|
input.lsp.some((status) => status === "error")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
||||||
|
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||||
|
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||||
|
if (input.issue) return "bg-icon-warning-base"
|
||||||
|
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||||
|
return "bg-border-weak-base"
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ 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"
|
||||||
|
|
||||||
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 })))
|
||||||
@@ -19,16 +20,14 @@ export function StatusPopover() {
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const mcpIssue = createMemo(() => {
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
const issue = createMemo(() =>
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
hasNonBlockingServiceIssue({
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
if (failed) return "critical" as const
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
if (warn) return "warning" as const
|
}),
|
||||||
})
|
)
|
||||||
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
|
|
||||||
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -47,13 +46,11 @@ export function StatusPopover() {
|
|||||||
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
classList={{
|
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
ready: ready(),
|
||||||
"bg-icon-success-base": ready() && healthy(),
|
serverHealth: serverHealth(),
|
||||||
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
|
issue: issue(),
|
||||||
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
|
})}`}
|
||||||
"bg-border-weak-base": serverHealthy() || !ready(),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
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)
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const mcpIssue = createMemo(() => {
|
const issue = createMemo(() =>
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
hasNonBlockingServiceIssue({
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
if (failed) return "critical" as const
|
}),
|
||||||
if (warn) return "warning" as const
|
)
|
||||||
})
|
|
||||||
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
|
|
||||||
const state = createMemo<StatusPopoverState>(() => ({
|
const state = createMemo<StatusPopoverState>(() => ({
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
healthy: healthy(),
|
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
issue: mcpIssue(),
|
issue: issue(),
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
body: () => (
|
body: () => (
|
||||||
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
|
|||||||
const state = createMemo<StatusPopoverState>(() => ({
|
const state = createMemo<StatusPopoverState>(() => ({
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
healthy: serverHealth() === true,
|
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
issue: false,
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
body: () => (
|
body: () => (
|
||||||
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
|
|||||||
type StatusPopoverState = {
|
type StatusPopoverState = {
|
||||||
shown: boolean
|
shown: boolean
|
||||||
ready: boolean
|
ready: boolean
|
||||||
healthy: boolean
|
|
||||||
serverHealth: boolean | undefined
|
serverHealth: boolean | undefined
|
||||||
issue?: "critical" | "warning"
|
issue: boolean
|
||||||
label: string
|
label: string
|
||||||
onOpenChange: (value: boolean) => void
|
onOpenChange: (value: boolean) => void
|
||||||
body: () => JSX.Element
|
body: () => JSX.Element
|
||||||
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||||
const statusDotClass = () => ({
|
|
||||||
"absolute rounded-full": true,
|
|
||||||
"bg-icon-success-base": props.state.ready && props.state.healthy,
|
|
||||||
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
|
|
||||||
"bg-icon-critical-base":
|
|
||||||
props.state.serverHealth === false ||
|
|
||||||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
|
|
||||||
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
|
|
||||||
})
|
|
||||||
|
|
||||||
const popoverProps = {
|
const popoverProps = {
|
||||||
class:
|
class:
|
||||||
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
||||||
@@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
|||||||
<div class="relative size-4">
|
<div class="relative size-4">
|
||||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||||
<div
|
<div
|
||||||
classList={statusDotClass()}
|
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||||
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
|||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
|
onAutoFocus?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||||
onConnect?: () => void
|
onConnect?: () => void
|
||||||
@@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const authToken = connection.type === "http" ? connection.authToken : false
|
const authToken = connection.type === "http" ? connection.authToken : false
|
||||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, [
|
||||||
|
"pty",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
"autoFocus",
|
||||||
|
"onAutoFocus",
|
||||||
|
"onConnect",
|
||||||
|
"onConnectError",
|
||||||
|
])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
@@ -283,7 +292,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 }
|
||||||
|
|
||||||
@@ -308,8 +322,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(() => {
|
||||||
@@ -387,6 +403,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?.()
|
||||||
@@ -416,6 +433,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
fitAddon = fit
|
fitAddon = fit
|
||||||
serializeAddon = serializer
|
serializeAddon = serializer
|
||||||
|
|
||||||
|
const active = document.activeElement
|
||||||
t.open(container)
|
t.open(container)
|
||||||
useTerminalUiBindings({
|
useTerminalUiBindings({
|
||||||
container,
|
container,
|
||||||
@@ -425,7 +443,22 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
handleLinkClick,
|
handleLinkClick,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (local.autoFocus !== false) focusTerminal()
|
if (local.autoFocus === true) {
|
||||||
|
focusTerminal()
|
||||||
|
local.onAutoFocus?.()
|
||||||
|
}
|
||||||
|
if (local.autoFocus !== true) {
|
||||||
|
const restoreFocus = () => {
|
||||||
|
const current = document.activeElement
|
||||||
|
if (current !== container && !container.contains(current)) return
|
||||||
|
t.blur()
|
||||||
|
t.textarea?.blur()
|
||||||
|
if (active instanceof HTMLElement && active.isConnected) active.focus()
|
||||||
|
}
|
||||||
|
restoreFocus()
|
||||||
|
const timer = setTimeout(restoreFocus, 0)
|
||||||
|
cleanups.push(() => clearTimeout(timer))
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof document !== "undefined" && document.fonts) {
|
if (typeof document !== "undefined" && document.fonts) {
|
||||||
void document.fonts.ready.then(scheduleFit)
|
void document.fonts.ready.then(scheduleFit)
|
||||||
@@ -570,6 +603,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) => {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
|
|||||||
return mount
|
return mount
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
id: "tab.new",
|
id: "tab.new",
|
||||||
category: "tab",
|
category: "tab",
|
||||||
title: language.t("command.session.new"),
|
title: language.t("command.session.new"),
|
||||||
keybind: "mod+t",
|
keybind: "mod+t,mod+n",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: openNewTab,
|
onSelect: openNewTab,
|
||||||
},
|
},
|
||||||
@@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
"md:pl-4": !mac(),
|
"md:pl-4": !mac(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChannelIndicator />
|
<ChannelIndicator debugTools={props.debugTools} />
|
||||||
<Show when={windows() || linux()}>
|
<Show when={windows() || linux()}>
|
||||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
<ChannelIndicator />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChannelIndicator debugTools={props.debugTools} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,12 +747,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelIndicator() {
|
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||||
|
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||||
|
if (channel === "dev" && props.debugTools) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
|
||||||
|
onClick={props.debugTools.toggle}
|
||||||
|
aria-label="Toggle debug tools"
|
||||||
|
aria-pressed={props.debugTools.visible}
|
||||||
|
>
|
||||||
|
DEV
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
{["beta", "dev"].includes(channel) && (
|
||||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
{channel.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
|
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
|
||||||
|
|
||||||
|
const paletteOptions: CommandOption[] = [
|
||||||
|
{ id: "settings.open", title: "Open settings" },
|
||||||
|
{ id: "session.undo", title: "Undo" },
|
||||||
|
{ id: "file.open", title: "Open file" },
|
||||||
|
{ id: "hidden", title: "Hidden", hidden: true },
|
||||||
|
{ id: "disabled", title: "Disabled", disabled: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe("commandPaletteOptions", () => {
|
||||||
|
test("keeps visible enabled commands", () => {
|
||||||
|
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("upsertCommandRegistration", () => {
|
describe("upsertCommandRegistration", () => {
|
||||||
test("replaces keyed registrations", () => {
|
test("replaces keyed registrations", () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
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"
|
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
|
||||||
const SUGGESTED_PREFIX = "suggested."
|
const SUGGESTED_PREFIX = "suggested."
|
||||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||||
|
|
||||||
@@ -87,6 +87,13 @@ export interface CommandOption {
|
|||||||
onHighlight?: () => (() => void) | void
|
onHighlight?: () => (() => void) | void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commandPaletteOptions(options: CommandOption[]) {
|
||||||
|
return options.filter(
|
||||||
|
(option) =>
|
||||||
|
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
||||||
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
||||||
}
|
}
|
||||||
@@ -375,7 +382,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showPalette = () => {
|
const showPalette = () => {
|
||||||
run("file.open", "palette")
|
run(PALETTE_ID, "palette")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
|||||||
import type { createServerSdkContext } from "./server-sdk"
|
import type { createServerSdkContext } from "./server-sdk"
|
||||||
import type { createServerSyncContextInner } from "./server-sync"
|
import type { createServerSyncContextInner } from "./server-sync"
|
||||||
import type { State } from "./global-sync/types"
|
import type { State } from "./global-sync/types"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const sessionFields = new Set([
|
const sessionFields = new Set([
|
||||||
@@ -15,6 +16,7 @@ const sessionFields = new Set([
|
|||||||
"permission",
|
"permission",
|
||||||
"question",
|
"question",
|
||||||
"message",
|
"message",
|
||||||
|
"session_message",
|
||||||
"part",
|
"part",
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
])
|
])
|
||||||
@@ -114,7 +116,6 @@ export const createDirSyncContext = (
|
|||||||
await serverSync.session.sync(sessionID, options)
|
await serverSync.session.sync(sessionID, options)
|
||||||
index(sessionID)
|
index(sessionID)
|
||||||
},
|
},
|
||||||
diff: serverSync.session.diff,
|
|
||||||
todo: serverSync.session.todo,
|
todo: serverSync.session.todo,
|
||||||
history: serverSync.session.history,
|
history: serverSync.session.history,
|
||||||
evict(sessionID: string) {
|
evict(sessionID: string) {
|
||||||
@@ -123,9 +124,9 @@ export const createDirSyncContext = (
|
|||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const [store, setStore] = current()
|
const [store, setStore] = current()
|
||||||
setStore("limit", (value) => value + count)
|
setStore("limit", (value) => value + count)
|
||||||
const response = await client.session.list()
|
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||||
const sessions = (response.data ?? [])
|
const sessions = response.data
|
||||||
.filter((session) => !!session?.id)
|
.map(normalizeSessionInfo)
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
sessions.forEach(serverSync.session.remember)
|
sessions.forEach(serverSync.session.remember)
|
||||||
@@ -133,7 +134,7 @@ export const createDirSyncContext = (
|
|||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
await serverSDK.api.session.archive({ sessionID, directory })
|
||||||
current()[1](
|
current()[1](
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -1,14 +1,39 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
import {
|
||||||
|
bootstrapDirectory,
|
||||||
|
loadAgentsQuery,
|
||||||
|
loadCommands,
|
||||||
|
loadPathQuery,
|
||||||
|
loadProjectsQuery,
|
||||||
|
loadProvidersQuery,
|
||||||
|
loadReferencesQuery,
|
||||||
|
} from "./bootstrap"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { createServerSession } from "../server-session"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
const api = {
|
||||||
|
agent: { list: async () => ({ location: {}, data: [] }) },
|
||||||
|
provider: { list: async () => ({ location: {}, data: [] }) },
|
||||||
|
model: {
|
||||||
|
list: async () => ({ location: {}, data: [] }),
|
||||||
|
default: async () => ({ location: {}, data: null }),
|
||||||
|
},
|
||||||
|
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||||
|
project: {
|
||||||
|
list: async () => [],
|
||||||
|
current: async () => ({ id: "project", directory: "/project" }),
|
||||||
|
},
|
||||||
|
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||||
|
reference: { list: async () => ({ location: {}, data: [] }) },
|
||||||
|
vcs: { get: async () => ({ location: {}, data: {} }) },
|
||||||
|
} as unknown as ServerApi
|
||||||
|
|
||||||
function directoryState() {
|
function directoryState() {
|
||||||
return createStore<State>({
|
return createStore<State>({
|
||||||
@@ -41,6 +66,7 @@ function directoryState() {
|
|||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
})
|
})
|
||||||
@@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
|
|||||||
sdk: {
|
sdk: {
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||||
config: { get: async () => ({ data: {} }) },
|
config: { get: async () => ({ data: {} }) },
|
||||||
session: { status: async () => ({ data: {} }) },
|
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
command: {
|
command: {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
@@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
},
|
},
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||||
} as unknown as OpencodeClient,
|
} as unknown as OpencodeClient,
|
||||||
|
api,
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
@@ -98,79 +124,108 @@ describe("bootstrapDirectory", () => {
|
|||||||
expect(store.status).toBe("complete")
|
expect(store.status).toBe("complete")
|
||||||
expect(mcpReads).toEqual([])
|
expect(mcpReads).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("seeds session status even while warming session info stalls", async () => {
|
|
||||||
const [store, setStore] = directoryState()
|
|
||||||
const stalled = Promise.withResolvers<never>()
|
|
||||||
const client = {
|
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
|
||||||
config: { get: async () => ({ data: {} }) },
|
|
||||||
session: {
|
|
||||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
|
||||||
get: () => stalled.promise,
|
|
||||||
},
|
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
|
||||||
command: { list: async () => ({ data: [] }) },
|
|
||||||
permission: { list: async () => ({ data: [] }) },
|
|
||||||
question: { list: async () => ({ data: [] }) },
|
|
||||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
|
||||||
mcp: { status: async () => ({ data: {} }) },
|
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
|
||||||
} as unknown as OpencodeClient
|
|
||||||
const session = createServerSession(client)
|
|
||||||
const stale: Session = {
|
|
||||||
id: "ses_stale",
|
|
||||||
slug: "ses_stale",
|
|
||||||
projectID: "project",
|
|
||||||
directory: "/project",
|
|
||||||
title: "stale",
|
|
||||||
version: "1",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
}
|
|
||||||
session.remember(stale)
|
|
||||||
session.set("session_status", stale.id, { type: "busy" })
|
|
||||||
|
|
||||||
await bootstrapDirectory({
|
|
||||||
directory: "/project",
|
|
||||||
scope: ServerScope.local,
|
|
||||||
mcp: false,
|
|
||||||
global: {
|
|
||||||
config: {} satisfies Config,
|
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
|
||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
sdk: client,
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
|
||||||
loadSessions() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryClient: new QueryClient(),
|
|
||||||
session,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deadline = Date.now() + 500
|
|
||||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
|
||||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const client = {} as OpencodeClient
|
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||||
|
const api = {} as CatalogApi
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||||
"https://debian.example",
|
})
|
||||||
null,
|
|
||||||
"providers",
|
test("loads the current provider and model catalog", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
provider: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(["provider", input])
|
||||||
|
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(["model", input])
|
||||||
|
return { location: {}, data: [] }
|
||||||
|
},
|
||||||
|
default: async (input: unknown) => {
|
||||||
|
calls.push(["default", input])
|
||||||
|
return { location: {}, data: null }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as CatalogApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["provider", { location: { directory: "/repo" } }],
|
||||||
|
["model", { location: { directory: "/repo" } }],
|
||||||
|
["default", { location: { directory: "/repo" } }],
|
||||||
])
|
])
|
||||||
|
expect(result.connected).toEqual(["openai"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads agents from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { location: {}, data: [] }
|
||||||
|
},
|
||||||
|
} as unknown as AgentApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads commands from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return {
|
||||||
|
location: {},
|
||||||
|
data: [{ name: "review", template: "Review files", source: "command" as const }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as CommandApi
|
||||||
|
|
||||||
|
const result = await loadCommands("/repo", api)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads projects from the current endpoint", async () => {
|
||||||
|
const api = {
|
||||||
|
list: async () => [
|
||||||
|
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
|
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
|
],
|
||||||
|
} as unknown as ProjectApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||||
|
|
||||||
|
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads references from the current location-scoped endpoint", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const api = {
|
||||||
|
list: async (input: unknown) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||||
|
},
|
||||||
|
} as unknown as ReferenceApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ import type {
|
|||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type {
|
||||||
|
AgentListInput,
|
||||||
|
AgentListOutput,
|
||||||
|
CatalogApi,
|
||||||
|
CommandInfo,
|
||||||
|
CommandListInput,
|
||||||
|
CommandListOutput,
|
||||||
|
McpApi,
|
||||||
|
PathGetInput,
|
||||||
|
PathGetOutput,
|
||||||
|
PermissionApi,
|
||||||
|
ProjectCurrentInput,
|
||||||
|
ProjectCurrentOutput,
|
||||||
|
ProjectListOutput,
|
||||||
|
QuestionApi,
|
||||||
|
ReferenceListInput,
|
||||||
|
ReferenceListOutput,
|
||||||
|
SessionApi,
|
||||||
|
VcsApi,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
@@ -16,12 +36,20 @@ import { batch } from "solid-js"
|
|||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import {
|
||||||
|
cmp,
|
||||||
|
normalizeAgentList,
|
||||||
|
normalizePermissionRequest,
|
||||||
|
normalizeProjectInfo,
|
||||||
|
normalizeProviderList,
|
||||||
|
} from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
|||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
type ProjectApi = {
|
||||||
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathApi = {
|
||||||
|
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.project.list().then((x) => {
|
api.list().then((projects) => {
|
||||||
return (x.data ?? [])
|
return projects
|
||||||
.filter((p) => !!p?.id)
|
.filter((p) => !!p?.id)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
|
.map(normalizeProjectInfo)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
}),
|
}),
|
||||||
@@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
|||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
serverSDK: OpencodeClient
|
||||||
|
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
|
||||||
|
protocol?: Promise<ServerProtocol>
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
@@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: {
|
|||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
() =>
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
input.queryClient.fetchQuery(
|
||||||
|
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||||
|
),
|
||||||
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
@@ -162,44 +205,117 @@ function warmSessions(input: {
|
|||||||
ids: string[]
|
ids: string[]
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
sdk: OpencodeClient
|
api: SessionApi
|
||||||
}) {
|
}) {
|
||||||
const known = new Set(input.store.session.map((item) => item.id))
|
const known = new Set(input.store.session.map((item) => item.id))
|
||||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||||
if (ids.length === 0) return Promise.resolve()
|
if (ids.length === 0) return Promise.resolve()
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
ids.map((sessionID) =>
|
ids.map((sessionID) =>
|
||||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||||
const session = x.data
|
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||||
if (!session?.id) return
|
),
|
||||||
mergeSession(input.setStore, session)
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadProvidersQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string | null,
|
||||||
|
sdk: CatalogApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "providers"],
|
queryKey: [scope, directory, "providers"],
|
||||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) {
|
||||||
|
const result = await legacy.provider.list()
|
||||||
|
return normalizeProviderList(result.data!)
|
||||||
|
}
|
||||||
|
const location = directory ? { location: { directory } } : undefined
|
||||||
|
const [providers, models, defaultModel] = await Promise.all([
|
||||||
|
sdk.provider.list(location),
|
||||||
|
sdk.model.list(location),
|
||||||
|
sdk.model.default(location),
|
||||||
|
])
|
||||||
|
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
type AgentListApi = {
|
||||||
|
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandListApi = {
|
||||||
|
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceListApi = {
|
||||||
|
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAgentsQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
sdk: AgentListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "agents"],
|
queryKey: [scope, directory, "agents"],
|
||||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||||
|
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
export const loadCommands = (
|
||||||
|
directory: string,
|
||||||
|
api: CommandListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
): Promise<CommandInfo[]> =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) {
|
||||||
|
return ((await legacy.command.list()).data ?? []).map((command) => {
|
||||||
|
const [providerID, id] = command.model?.split("/") ?? []
|
||||||
|
return {
|
||||||
|
name: command.name,
|
||||||
|
template: command.template,
|
||||||
|
description: command.description,
|
||||||
|
agent: command.agent,
|
||||||
|
model: providerID && id ? { providerID, id } : undefined,
|
||||||
|
subtask: command.subtask,
|
||||||
|
source: command.source === "skill" ? undefined : command.source,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return api.list({ location: { directory } }).then((result) => result.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadReferencesQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
api: ReferenceListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions<ReferenceInfo[]>({
|
queryOptions<ReferenceInfo[]>({
|
||||||
queryKey: [scope, directory, "references"] as const,
|
queryKey: [scope, directory, "references"] as const,
|
||||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
queryFn: () =>
|
||||||
|
retry(async () => {
|
||||||
|
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
||||||
|
return api.list({ location: { directory } }).then((result) => result.data)
|
||||||
|
}).catch(() => []),
|
||||||
placeholderData: [],
|
placeholderData: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: {
|
|||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
|
api: CatalogApi & {
|
||||||
|
readonly agent: AgentListApi
|
||||||
|
readonly command: CommandListApi
|
||||||
|
readonly mcp: McpApi
|
||||||
|
readonly path: PathApi
|
||||||
|
readonly permission: PermissionApi
|
||||||
|
readonly project: ProjectApi
|
||||||
|
readonly question: QuestionApi
|
||||||
|
readonly reference: ReferenceListApi
|
||||||
|
readonly session: SessionApi
|
||||||
|
readonly vcs: VcsApi
|
||||||
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
@@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
|
protocol?: Promise<ServerProtocol>
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
@@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: {
|
|||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.sdk.session.status().then(async (x) => {
|
|
||||||
if (!input.session) {
|
|
||||||
input.setStore("session_status", x.data!)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const statuses = x.data ?? {}
|
|
||||||
input.session.set(
|
|
||||||
"session_status",
|
|
||||||
produce((draft) => {
|
|
||||||
for (const sessionID of Object.keys(draft)) {
|
|
||||||
if (statuses[sessionID]) continue
|
|
||||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
|
||||||
input.session.set("session_status", sessionID, reconcile(status))
|
|
||||||
}
|
|
||||||
// Warm session info only after seeding statuses so a stalled session
|
|
||||||
// fetch cannot park busy indicators behind it, mirroring how live
|
|
||||||
// session.status events apply first and resolve info in the background.
|
|
||||||
await Promise.all(
|
|
||||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
(() =>
|
||||||
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
|
input.setStore("project", project.id),
|
||||||
|
)),
|
||||||
!seededPath &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
input.queryClient
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
|
||||||
if (next) input.setStore("project", next)
|
.then((data) => {
|
||||||
})),
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
})),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.vcs.get().then((x) => {
|
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
|
||||||
const next = x.data ?? input.store.vcs
|
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
input.mcp &&
|
||||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
(() =>
|
||||||
|
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||||
|
input.setStore("command", commands),
|
||||||
|
)),
|
||||||
|
() =>
|
||||||
|
input.queryClient.fetchQuery(
|
||||||
|
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||||
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
(async () => {
|
||||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||||
|
return input.api.permission.request
|
||||||
|
.list({ location: { directory: input.directory } })
|
||||||
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
|
})().then((permissions) => {
|
||||||
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||||
)
|
)
|
||||||
const warm = input.session
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
return warm.then(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.permission ?? input.store.permission
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
@@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.question.list().then((x) => {
|
(async () => {
|
||||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
return input.api.question.request
|
||||||
|
.list({ location: { directory: input.directory } })
|
||||||
|
.then((result) => result.data)
|
||||||
|
})().then((questions) => {
|
||||||
|
const ids = questions.map((question) => question.sessionID)
|
||||||
|
const grouped = groupBySession(
|
||||||
|
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||||
|
)
|
||||||
const warm = input.session
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
return warm.then(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.question ?? input.store.question
|
const current = input.session?.data.question ?? input.store.question
|
||||||
@@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
input.mcp &&
|
||||||
|
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
input.queryClient
|
||||||
const project = getFilename(input.directory)
|
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||||
showToast({
|
.catch((err) => {
|
||||||
variant: "error",
|
const project = getFilename(input.directory)
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
showToast({
|
||||||
description: formatServerError(err, input.translate),
|
variant: "error",
|
||||||
})
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
}),
|
description: formatServerError(err, input.translate),
|
||||||
|
})
|
||||||
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|||||||
@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps non-bootstrapping children passive until a real directory access", () => {
|
||||||
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
|
const offset = querySingles.length
|
||||||
|
const bootstraps: string[] = []
|
||||||
|
|
||||||
|
const dispose = createOwner((owner) => {
|
||||||
|
manager = createChildStoreManager({
|
||||||
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
|
isBooting: () => false,
|
||||||
|
isLoadingSessions: () => false,
|
||||||
|
onBootstrap(directory) {
|
||||||
|
bootstraps.push(directory)
|
||||||
|
},
|
||||||
|
onMcp() {},
|
||||||
|
onDispose() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryOptions: queryOptionsApi,
|
||||||
|
global: { provider },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!manager) throw new Error("manager required")
|
||||||
|
const [store] = manager.child("/project", { bootstrap: false })
|
||||||
|
const queries = querySingles.slice(offset)
|
||||||
|
|
||||||
|
expect(queries).toHaveLength(6)
|
||||||
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
|
expect(queries[3]?.().enabled).toBe(false)
|
||||||
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
|
expect(queries[5]?.().enabled).toBe(false)
|
||||||
|
expect(store.path.directory).toBe("/project")
|
||||||
|
expect(store.provider_ready).toBe(false)
|
||||||
|
expect(store.lsp_ready).toBe(false)
|
||||||
|
expect(bootstraps).toEqual([])
|
||||||
|
|
||||||
|
manager.child("/project")
|
||||||
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
expect(queries[3]?.().enabled).toBe(true)
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
|
expect(queries[5]?.().enabled).toBe(true)
|
||||||
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
|
manager.child("/project", { bootstrap: false })
|
||||||
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
|
|||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
const mcpDirectories = new Set<string>()
|
||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
const activeDirectories = new Set<string>()
|
||||||
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
|
|||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
mcpDirectories.delete(key)
|
mcpDirectories.delete(key)
|
||||||
mcpToggles.delete(key)
|
mcpToggles.delete(key)
|
||||||
|
activeDirectories.delete(key)
|
||||||
|
activationToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
|
|||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
const providerQuery = useQuery(() => ({
|
||||||
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
...input.queryOptions.providers(key),
|
||||||
|
enabled: instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
const referenceQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.references(key),
|
||||||
|
enabled: instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return !providerQuery.isLoading
|
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
@@ -236,7 +247,7 @@ export function createChildStoreManager(input: {
|
|||||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||||
},
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return !lspQuery.isLoading
|
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||||
},
|
},
|
||||||
get lsp() {
|
get lsp() {
|
||||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
@@ -244,12 +255,14 @@ export function createChildStoreManager(input: {
|
|||||||
vcs: vcsStore.value,
|
vcs: vcsStore.value,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
@@ -290,6 +303,7 @@ export function createChildStoreManager(input: {
|
|||||||
pinForOwner(key)
|
pinForOwner(key)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap) activate(key)
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
}
|
}
|
||||||
@@ -301,6 +315,7 @@ export function createChildStoreManager(input: {
|
|||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap) activate(key)
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
}
|
}
|
||||||
@@ -314,6 +329,16 @@ export function createChildStoreManager(input: {
|
|||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passive Home/project metadata reads must not initialize the directory.
|
||||||
|
// A real directory access enables these queries once for the store lifetime.
|
||||||
|
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||||
|
// updated-time v2.session.list, remove any Home-only passive child creation.
|
||||||
|
function activate(key: DirectoryKey) {
|
||||||
|
if (activeDirectories.has(key)) return
|
||||||
|
activeDirectories.add(key)
|
||||||
|
activationToggles.get(key)?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
function disableMcp(directory: string) {
|
function disableMcp(directory: string) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
if (!mcpDirectories.delete(key)) return
|
if (!mcpDirectories.delete(key)) return
|
||||||
@@ -360,6 +385,7 @@ export function createChildStoreManager(input: {
|
|||||||
unpin,
|
unpin,
|
||||||
pinned,
|
pinned,
|
||||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||||
|
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||||
disableMcp,
|
disableMcp,
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
runEviction,
|
runEviction,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
|
|||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
...input,
|
...input,
|
||||||
@@ -243,10 +244,26 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("ignores an archived session absent from a passive directory store", () => {
|
||||||
|
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session).toEqual([])
|
||||||
|
expect(store.sessionTotal).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const item of cases) {
|
for (const item of cases) {
|
||||||
@@ -270,7 +287,10 @@ describe("applyDirectoryEvent", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "session.deleted", properties: { info: item.info } },
|
event: {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
|
||||||
|
},
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push() {},
|
push() {},
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
@@ -146,15 +146,14 @@ export function applyDirectoryEvent(input: {
|
|||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: Session }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (info.time.archived) {
|
if (info.time.archived) {
|
||||||
|
if (!result.found) break
|
||||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||||
if (result.found) {
|
input.setStore(
|
||||||
input.setStore(
|
"session",
|
||||||
"session",
|
produce((draft) => {
|
||||||
produce((draft) => {
|
draft.splice(result.index, 1)
|
||||||
draft.splice(result.index, 1)
|
}),
|
||||||
}),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
@@ -172,8 +171,11 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const sessionID = properties.info?.id ?? properties.sessionID
|
||||||
|
if (!sessionID) break
|
||||||
|
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||||
|
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore(
|
input.setStore(
|
||||||
"session",
|
"session",
|
||||||
@@ -182,14 +184,77 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info?.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "session.renamed": {
|
||||||
|
const properties = event.properties as { sessionID: string; title: string }
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore("session", result.index, (session) => ({
|
||||||
|
...session,
|
||||||
|
title: properties.title,
|
||||||
|
time: { ...session.time, updated: Date.now() },
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.usage.updated": {
|
||||||
|
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore("session", result.index, (session) => ({
|
||||||
|
...session,
|
||||||
|
cost: properties.cost,
|
||||||
|
tokens: properties.tokens,
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.archived": {
|
||||||
|
const properties = event.properties as { sessionID: string }
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
const info = input.store.session[result.index]
|
||||||
|
input.setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => void draft.splice(result.index, 1)),
|
||||||
|
)
|
||||||
|
cleanupSessionCaches(input.setStore, properties.sessionID)
|
||||||
|
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.moved": {
|
||||||
|
const properties = event.properties as {
|
||||||
|
sessionID: string
|
||||||
|
location: { directory: string; workspaceID?: string }
|
||||||
|
projectID?: string
|
||||||
|
subpath?: string
|
||||||
|
}
|
||||||
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
if (!result.found) break
|
||||||
|
if (properties.location.directory === input.directory) {
|
||||||
|
input.setStore("session", result.index, (session) => ({
|
||||||
|
...session,
|
||||||
|
projectID: properties.projectID ?? session.projectID,
|
||||||
|
workspaceID: properties.location.workspaceID,
|
||||||
|
directory: properties.location.directory,
|
||||||
|
path: properties.subpath,
|
||||||
|
time: { ...session.time, updated: Date.now() },
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const info = input.store.session[result.index]
|
||||||
|
input.setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => void draft.splice(result.index, 1)),
|
||||||
|
)
|
||||||
|
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
break
|
||||||
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import {
|
||||||
|
applyHomeSessionEvent,
|
||||||
|
appendHomeSessionEvent,
|
||||||
|
HOME_V2_SESSION_PAGE_LIMIT,
|
||||||
|
loadHomeSessionIndex,
|
||||||
|
homeSessionIndexSessions,
|
||||||
|
homeSessionIndexRefresh,
|
||||||
|
parseHomeSessionIndex,
|
||||||
|
retainHomeSessions,
|
||||||
|
} from "./home-session-index"
|
||||||
|
|
||||||
|
const session = (input: {
|
||||||
|
id: string
|
||||||
|
directory?: string
|
||||||
|
parentID?: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}) => ({
|
||||||
|
id: input.id,
|
||||||
|
parentID: input.parentID,
|
||||||
|
projectID: "project",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
|
||||||
|
title: input.id,
|
||||||
|
location: { directory: input.directory ?? "/project" },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Home V2 session index", () => {
|
||||||
|
test("loads the Home index with one global V2 request", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const result = await loadHomeSessionIndex(async (input) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.sessions).toHaveLength(1)
|
||||||
|
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads subsequent pages until the session index is complete", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const controller = new AbortController()
|
||||||
|
const result = await loadHomeSessionIndex(
|
||||||
|
async (input, options) => {
|
||||||
|
calls.push({ input, signal: options.signal })
|
||||||
|
if (!("cursor" in input)) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||||
|
session({ id: `page-1-${index}` }),
|
||||||
|
),
|
||||||
|
cursor: { next: "next-page" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
|
||||||
|
{
|
||||||
|
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps visible roots to Home session summaries", () => {
|
||||||
|
const activeNull = {
|
||||||
|
...session({ id: "active-null", updated: 20 }),
|
||||||
|
time: { created: 1, updated: 20, archived: null },
|
||||||
|
} as unknown as SessionV2Info
|
||||||
|
const result = parseHomeSessionIndex([
|
||||||
|
session({ id: "root", updated: 30 }),
|
||||||
|
activeNull,
|
||||||
|
session({ id: "child", parentID: "root", updated: 40 }),
|
||||||
|
session({ id: "archived", archived: 50, updated: 50 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "root",
|
||||||
|
slug: "root",
|
||||||
|
version: "",
|
||||||
|
directory: "/project",
|
||||||
|
projectID: "project",
|
||||||
|
title: "root",
|
||||||
|
time: { created: 1, updated: 30 },
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "active-null",
|
||||||
|
time: { created: 1, updated: 20, archived: null },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the per-directory Home retention limit", () => {
|
||||||
|
const now = 10 * 60 * 60 * 1000
|
||||||
|
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||||
|
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||||
|
directory: index % 2 === 0 ? "/one" : "/two",
|
||||||
|
}))
|
||||||
|
|
||||||
|
const retained = retainHomeSessions(sessions, 10, now)
|
||||||
|
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
||||||
|
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replays session events over the loaded index", () => {
|
||||||
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
|
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||||
|
|
||||||
|
const afterCreate = applyHomeSessionEvent(initial, {
|
||||||
|
type: "session.created",
|
||||||
|
properties: { sessionID: created.id, info: created },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
applyHomeSessionEvent(afterCreate, {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: initial[0]!.id, info: initial[0]! },
|
||||||
|
}),
|
||||||
|
).toEqual([created])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies only events newer than the index baseline", () => {
|
||||||
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
|
const stale = { ...initial[0], title: "stale" }
|
||||||
|
const current = { ...initial[0], title: "current" }
|
||||||
|
const first = appendHomeSessionEvent(undefined, {
|
||||||
|
type: "session.updated",
|
||||||
|
properties: { sessionID: stale.id, info: stale },
|
||||||
|
})
|
||||||
|
const events = appendHomeSessionEvent(first, {
|
||||||
|
type: "session.updated",
|
||||||
|
properties: { sessionID: current.id, info: current },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refetches after reconnect, disposal, and session moves", () => {
|
||||||
|
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||||
|
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||||
|
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||||
|
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { QueryClient } from "@tanstack/solid-query"
|
||||||
|
import { trimSessions } from "./session-trim"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||||
|
|
||||||
|
export type HomeSessionEvent = {
|
||||||
|
type: "session.created" | "session.updated" | "session.deleted"
|
||||||
|
properties: { sessionID: string; info: Session }
|
||||||
|
}
|
||||||
|
export type HomeSessionEvents = {
|
||||||
|
sequence: number
|
||||||
|
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||||
|
}
|
||||||
|
export type HomeSessionIndex = {
|
||||||
|
sessions: Session[]
|
||||||
|
eventSequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||||
|
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||||
|
|
||||||
|
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||||
|
|
||||||
|
export async function loadHomeSessionIndex(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<HomeSessionPage>,
|
||||||
|
eventSequence = 0,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const data: SessionV2Info[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const response = await list(
|
||||||
|
{
|
||||||
|
limit: HOME_V2_SESSION_PAGE_LIMIT,
|
||||||
|
order: "desc",
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
const page = response.data!
|
||||||
|
data.push(...page.data)
|
||||||
|
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||||
|
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||||
|
cursor = page.cursor.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
|
||||||
|
const sequence = (current?.sequence ?? 0) + 1
|
||||||
|
return {
|
||||||
|
sequence,
|
||||||
|
entries: [...(current?.entries ?? []), { sequence, event }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
|
||||||
|
return {
|
||||||
|
sequence: current?.sequence ?? sequence,
|
||||||
|
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||||
|
if (!index) return []
|
||||||
|
return (events?.entries ?? [])
|
||||||
|
.filter((entry) => entry.sequence > index.eventSequence)
|
||||||
|
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||||
|
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||||
|
const indexKey = homeSessionIndexKey(server)
|
||||||
|
const eventsKey = homeSessionEventsKey(server)
|
||||||
|
let connected = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
indexKey,
|
||||||
|
eventsKey,
|
||||||
|
eventSequence() {
|
||||||
|
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
|
||||||
|
},
|
||||||
|
complete(sequence: number) {
|
||||||
|
// Keep events received after the fetch began so its response cannot overwrite them.
|
||||||
|
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
|
||||||
|
},
|
||||||
|
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||||
|
return homeSessionIndexSessions(index, events)
|
||||||
|
},
|
||||||
|
apply(event: HomeSessionEvent) {
|
||||||
|
if (!queryClient.getQueryState(indexKey)) return
|
||||||
|
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
|
||||||
|
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
|
||||||
|
queryClient.setQueryData(eventsKey, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
|
||||||
|
if (index) {
|
||||||
|
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
|
||||||
|
sessions: homeSessionIndexSessions(index, next),
|
||||||
|
eventSequence: next.sequence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
|
||||||
|
},
|
||||||
|
refresh(event: Event["type"]) {
|
||||||
|
const result = homeSessionIndexRefresh(event, connected)
|
||||||
|
connected = result.connected
|
||||||
|
if (!result.refetch) return
|
||||||
|
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(v2): This deliberately dumb full-table scan is necessary because the
|
||||||
|
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||||
|
// multiple directories. A bounded page could omit an old session updated today.
|
||||||
|
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||||
|
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||||
|
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||||
|
return sessions.flatMap((item) => {
|
||||||
|
if (item.parentID || typeof item.time.archived === "number") return []
|
||||||
|
return [toLegacySummary(item)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||||
|
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||||
|
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||||
|
const info = event.properties.info
|
||||||
|
const index = sessions.findIndex((session) => session.id === info.id)
|
||||||
|
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||||
|
if (index === -1) return sessions
|
||||||
|
return sessions.toSpliced(index, 1)
|
||||||
|
}
|
||||||
|
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
|
||||||
|
if (index === -1) return [...sessions, info]
|
||||||
|
return sessions.with(index, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLegacySummary(session: SessionV2Info): Session {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
slug: session.id,
|
||||||
|
projectID: session.projectID,
|
||||||
|
workspaceID: session.location.workspaceID,
|
||||||
|
directory: session.location.directory,
|
||||||
|
path: session.subpath,
|
||||||
|
parentID: session.parentID,
|
||||||
|
cost: session.cost,
|
||||||
|
tokens: session.tokens,
|
||||||
|
title: session.title,
|
||||||
|
agent: session.agent,
|
||||||
|
model: session.model,
|
||||||
|
version: "",
|
||||||
|
time: session.time,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,24 @@ describe("toggleMcp", () => {
|
|||||||
await toggleMcp(input("disabled"))
|
await toggleMcp(input("disabled"))
|
||||||
expect(calls).toEqual(["connect", "refresh"])
|
expect(calls).toEqual(["connect", "refresh"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not toggle a server while its connection is pending", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
await toggleMcp({
|
||||||
|
status: "pending",
|
||||||
|
connect: async () => {
|
||||||
|
calls.push("connect")
|
||||||
|
},
|
||||||
|
disconnect: async () => {
|
||||||
|
calls.push("disconnect")
|
||||||
|
},
|
||||||
|
authenticate: async () => {
|
||||||
|
calls.push("authenticate")
|
||||||
|
},
|
||||||
|
refresh: async () => {
|
||||||
|
calls.push("refresh")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
import type { McpServer } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export async function toggleMcp(input: {
|
export async function toggleMcp(input: {
|
||||||
status: McpStatus["status"]
|
status: McpServer["status"]["status"]
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
disconnect: () => Promise<void>
|
disconnect: () => Promise<void>
|
||||||
authenticate: () => Promise<void>
|
authenticate: () => Promise<void>
|
||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
if (input.status === "pending") return
|
||||||
await {
|
await {
|
||||||
connected: input.disconnect,
|
connected: input.disconnect,
|
||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Message,
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
Part,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
const msg = (id: string, sessionID: string) =>
|
const msg = (id: string, sessionID: string) =>
|
||||||
@@ -33,9 +26,10 @@ describe("app session cache", () => {
|
|||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
@@ -45,6 +39,7 @@ describe("app session cache", () => {
|
|||||||
session_diff: { ses_1: [] },
|
session_diff: { ses_1: [] },
|
||||||
todo: { ses_1: [] as Todo[] },
|
todo: { ses_1: [] as Todo[] },
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
question: { ses_1: [] as QuestionRequest[] },
|
||||||
@@ -67,9 +62,10 @@ describe("app session cache", () => {
|
|||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
@@ -79,6 +75,7 @@ describe("app session cache", () => {
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
message: { ses_1: [m] },
|
message: { ses_1: [m] },
|
||||||
|
session_message: {},
|
||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import type {
|
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Message,
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
Part,
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export const SESSION_CACHE_LIMIT = 40
|
export const SESSION_CACHE_LIMIT = 40
|
||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
@@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
|||||||
for (const sessionID of stale) {
|
for (const sessionID of stale) {
|
||||||
delete store.message[sessionID]
|
delete store.message[sessionID]
|
||||||
delete store.todo[sessionID]
|
delete store.todo[sessionID]
|
||||||
|
delete store.session_message[sessionID]
|
||||||
delete store.session_diff[sessionID]
|
delete store.session_diff[sessionID]
|
||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import type { RootLoadArgs } from "./types"
|
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||||
|
const result = await input.api.list({
|
||||||
|
directory: input.directory,
|
||||||
|
parentID: null,
|
||||||
|
limit: input.limit,
|
||||||
|
order: "desc",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
data: result.data.map(normalizeSessionInfo),
|
||||||
|
limit: input.limit,
|
||||||
|
limited: true,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||||
try {
|
try {
|
||||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||||
return {
|
return { data: result.data, limit: input.limit, limited: true } as const
|
||||||
data: result.data,
|
|
||||||
limit: input.limit,
|
|
||||||
limited: true,
|
|
||||||
} as const
|
|
||||||
} catch {
|
} catch {
|
||||||
const result = await input.list({ directory: input.directory, roots: true })
|
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||||
return {
|
return { data: result.data, limit: input.limit, limited: false } as const
|
||||||
data: result.data,
|
|
||||||
limit: input.limit,
|
|
||||||
limited: false,
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
Command,
|
|
||||||
Config,
|
Config,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpResource,
|
|
||||||
McpStatus,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Path,
|
Path,
|
||||||
@@ -13,11 +10,12 @@ import type {
|
|||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
|
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
@@ -35,7 +33,7 @@ export type ProjectMeta = {
|
|||||||
export type State = {
|
export type State = {
|
||||||
status: "loading" | "partial" | "complete"
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: CommandInfo[]
|
||||||
reference: ReferenceInfo[]
|
reference: ReferenceInfo[]
|
||||||
project: string
|
project: string
|
||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
@@ -51,7 +49,7 @@ export type State = {
|
|||||||
}
|
}
|
||||||
session_working(id: string): boolean
|
session_working(id: string): boolean
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SnapshotFileDiff[]
|
[sessionID: string]: FileDiffInfo[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
@@ -64,7 +62,7 @@ export type State = {
|
|||||||
}
|
}
|
||||||
mcp_ready: boolean
|
mcp_ready: boolean
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpServer["status"]
|
||||||
}
|
}
|
||||||
mcp_resource: {
|
mcp_resource: {
|
||||||
[key: string]: McpResource
|
[key: string]: McpResource
|
||||||
@@ -76,6 +74,9 @@ export type State = {
|
|||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: Message[]
|
||||||
}
|
}
|
||||||
|
session_message: {
|
||||||
|
[sessionID: string]: SessionMessageInfo[]
|
||||||
|
}
|
||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
@@ -128,18 +129,6 @@ export type DisposeCheck = {
|
|||||||
loadingSessions: boolean
|
loadingSessions: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootLoadArgs = {
|
|
||||||
directory: string
|
|
||||||
limit: number
|
|
||||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RootLoadResult = {
|
|
||||||
data?: Session[]
|
|
||||||
limit: number
|
|
||||||
limited: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAX_DIR_STORES = 30
|
export const MAX_DIR_STORES = 30
|
||||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||||
|
|||||||
@@ -1,36 +1,117 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
import { directoryKey, normalizeAgentList } from "./utils"
|
AgentListOutput,
|
||||||
|
ModelDefaultOutput,
|
||||||
const agent = (name = "build") =>
|
ModelListOutput,
|
||||||
({
|
ProviderListOutput,
|
||||||
name,
|
} from "@opencode-ai/client/promise"
|
||||||
mode: "primary",
|
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||||
permission: {},
|
|
||||||
options: {},
|
|
||||||
}) as Agent
|
|
||||||
|
|
||||||
describe("normalizeAgentList", () => {
|
describe("normalizeAgentList", () => {
|
||||||
test("keeps array payloads", () => {
|
test("adapts current agents to the app agent shape", () => {
|
||||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
const result = normalizeAgentList([
|
||||||
})
|
{
|
||||||
|
id: "build",
|
||||||
|
name: "Build",
|
||||||
|
mode: "primary",
|
||||||
|
hidden: false,
|
||||||
|
color: "primary",
|
||||||
|
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||||
|
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||||
|
system: "Build software",
|
||||||
|
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||||
|
},
|
||||||
|
] as AgentListOutput["data"])
|
||||||
|
|
||||||
test("wraps a single agent payload", () => {
|
expect(result).toEqual([
|
||||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
{
|
||||||
|
name: "build",
|
||||||
|
description: undefined,
|
||||||
|
mode: "primary",
|
||||||
|
hidden: false,
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
color: "primary",
|
||||||
|
permission: [{ permission: "read", pattern: "*", action: "allow" }],
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
variant: "high",
|
||||||
|
prompt: "Build software",
|
||||||
|
options: { temperature: 0.2, topP: 0.9 },
|
||||||
|
steps: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("extracts agents from keyed objects", () => {
|
describe("normalizePermissionRequest", () => {
|
||||||
|
test("adapts the current permission request to app state", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeAgentList({
|
normalizePermissionRequest({
|
||||||
build: agent("build"),
|
id: "permission-1",
|
||||||
docs: agent("docs"),
|
sessionID: "session-1",
|
||||||
|
action: "read",
|
||||||
|
resources: ["README.md"],
|
||||||
|
save: ["*.md"],
|
||||||
|
metadata: { path: "README.md" },
|
||||||
|
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||||
}),
|
}),
|
||||||
).toEqual([agent("build"), agent("docs")])
|
).toEqual({
|
||||||
|
id: "permission-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
permission: "read",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
always: ["*.md"],
|
||||||
|
metadata: { path: "README.md" },
|
||||||
|
tool: { messageID: "message-1", callID: "call-1" },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("drops invalid payloads", () => {
|
describe("normalizeProviderList", () => {
|
||||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
test("groups current models into the app provider catalog", () => {
|
||||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
const result = normalizeProviderList(
|
||||||
|
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "gpt-5",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
name: "GPT-5",
|
||||||
|
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||||
|
variants: [{ id: "high" }],
|
||||||
|
time: { released: 1 },
|
||||||
|
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
|
||||||
|
status: "active",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 128_000, output: 8_192 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-old",
|
||||||
|
modelID: "gpt-old",
|
||||||
|
providerID: "openai",
|
||||||
|
name: "GPT Old",
|
||||||
|
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||||
|
variants: [],
|
||||||
|
time: { released: 0 },
|
||||||
|
cost: [],
|
||||||
|
status: "deprecated",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 1, output: 1 },
|
||||||
|
},
|
||||||
|
] as ModelListOutput["data"],
|
||||||
|
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.connected).toEqual(["openai"])
|
||||||
|
expect(result.default).toEqual({ openai: "gpt-5" })
|
||||||
|
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
||||||
|
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
||||||
|
id: "gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
capabilities: { toolcall: true, attachment: true },
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
variants: { high: {} },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,152 @@
|
|||||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
|
AgentListOutput,
|
||||||
|
ModelDefaultOutput,
|
||||||
|
ModelListOutput,
|
||||||
|
PermissionV2Request,
|
||||||
|
ProviderListOutput,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
|
import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||||
|
|
||||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
function isAgent(input: unknown): input is Agent {
|
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||||
if (!input || typeof input !== "object") return false
|
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||||
const item = input as { name?: unknown; mode?: unknown }
|
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||||
if (typeof item.name !== "string") return false
|
name: agent.id,
|
||||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
description: agent.description,
|
||||||
|
mode: agent.mode,
|
||||||
|
hidden: agent.hidden,
|
||||||
|
temperature:
|
||||||
|
typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
|
||||||
|
topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
|
||||||
|
color: agent.color,
|
||||||
|
permission: agent.permissions.map((rule) => ({
|
||||||
|
permission: rule.action,
|
||||||
|
pattern: rule.resource,
|
||||||
|
action: rule.effect,
|
||||||
|
})),
|
||||||
|
model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
|
||||||
|
variant: agent.model?.variant,
|
||||||
|
prompt: agent.system,
|
||||||
|
options: agent.request.settings,
|
||||||
|
steps: agent.steps,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAgentList(input: unknown): Agent[] {
|
export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
|
||||||
if (Array.isArray(input)) return input.filter(isAgent)
|
if ("permission" in input) return input
|
||||||
if (isAgent(input)) return [input]
|
|
||||||
if (!input || typeof input !== "object") return []
|
|
||||||
return Object.values(input).filter(isAgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
|
||||||
return {
|
return {
|
||||||
...input,
|
id: input.id,
|
||||||
all: new Map(
|
sessionID: input.sessionID,
|
||||||
input.all.map(
|
permission: input.action,
|
||||||
(provider) =>
|
patterns: input.resources,
|
||||||
[
|
always: input.save ?? [],
|
||||||
provider.id,
|
metadata: input.metadata ?? {},
|
||||||
{
|
tool:
|
||||||
...provider,
|
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||||
models: Object.fromEntries(
|
}
|
||||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
}
|
||||||
),
|
|
||||||
},
|
export function normalizeProviderList(
|
||||||
] as const,
|
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||||
|
models?: ModelListOutput["data"],
|
||||||
|
defaultModel?: ModelDefaultOutput["data"],
|
||||||
|
): NormalizedProviderListResponse {
|
||||||
|
if (!Array.isArray(providers)) {
|
||||||
|
return {
|
||||||
|
...providers,
|
||||||
|
all: new Map(
|
||||||
|
providers.all.map((provider) => [
|
||||||
|
provider.id,
|
||||||
|
{
|
||||||
|
...provider,
|
||||||
|
models: Object.fromEntries(
|
||||||
|
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const all = new Map<string, Provider>()
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
all.set(provider.id, {
|
||||||
|
id: provider.id,
|
||||||
|
name: provider.name,
|
||||||
|
source: "custom",
|
||||||
|
env: [],
|
||||||
|
options: provider.settings ?? {},
|
||||||
|
models: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const model of models ?? []) {
|
||||||
|
const provider = all.get(model.providerID)
|
||||||
|
if (!provider || model.status === "deprecated") continue
|
||||||
|
const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
|
||||||
|
provider.models[model.id] = {
|
||||||
|
id: model.id,
|
||||||
|
providerID: model.providerID,
|
||||||
|
api: {
|
||||||
|
id: model.modelID,
|
||||||
|
url: "",
|
||||||
|
npm: model.package ?? provider.id,
|
||||||
|
},
|
||||||
|
name: model.name,
|
||||||
|
family: model.family,
|
||||||
|
capabilities: {
|
||||||
|
temperature: false,
|
||||||
|
reasoning: false,
|
||||||
|
attachment: model.capabilities.input.some((item) => item !== "text"),
|
||||||
|
toolcall: model.capabilities.tools,
|
||||||
|
input: {
|
||||||
|
text: model.capabilities.input.includes("text"),
|
||||||
|
audio: model.capabilities.input.includes("audio"),
|
||||||
|
image: model.capabilities.input.includes("image"),
|
||||||
|
video: model.capabilities.input.includes("video"),
|
||||||
|
pdf: model.capabilities.input.includes("pdf"),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
text: model.capabilities.output.includes("text"),
|
||||||
|
audio: model.capabilities.output.includes("audio"),
|
||||||
|
image: model.capabilities.output.includes("image"),
|
||||||
|
video: model.capabilities.output.includes("video"),
|
||||||
|
pdf: model.capabilities.output.includes("pdf"),
|
||||||
|
},
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: cost?.input ?? 0,
|
||||||
|
output: cost?.output ?? 0,
|
||||||
|
cache: {
|
||||||
|
read: cost?.cache.read ?? 0,
|
||||||
|
write: cost?.cache.write ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: model.limit,
|
||||||
|
status: model.status,
|
||||||
|
options: model.settings ?? {},
|
||||||
|
headers: model.headers ?? {},
|
||||||
|
release_date: new Date(model.time.released).toISOString().slice(0, 10),
|
||||||
|
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
connected: providers.map((provider) => provider.id),
|
||||||
|
default: Object.fromEntries(
|
||||||
|
providers.flatMap((provider) => {
|
||||||
|
const model =
|
||||||
|
defaultModel?.providerID === provider.id
|
||||||
|
? defaultModel
|
||||||
|
: models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
|
||||||
|
return model ? [[provider.id, model.id]] : []
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
vcs: project.vcs === "git" ? "git" : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||||
|
|
||||||
|
describe("hasCustomAgent", () => {
|
||||||
|
test("detects explicitly custom agents", () => {
|
||||||
|
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores built-in and unclassified agents", () => {
|
||||||
|
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveAgent", () => {
|
||||||
|
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
|
||||||
|
|
||||||
|
test("uses the requested available agent", () => {
|
||||||
|
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults to build", () => {
|
||||||
|
expect(resolveAgent(agents)?.name).toBe("build")
|
||||||
|
expect(resolveAgent(agents, "missing")?.name).toBe("build")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the first agent when build is unavailable", () => {
|
||||||
|
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
|
||||||
|
return items.some((item) => item.native === false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
|
||||||
|
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
import { batch, createEffect, createMemo, createRoot, onCleanup, startTransition } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
@@ -24,6 +26,13 @@ type Saved = {
|
|||||||
session: Record<string, State | undefined>
|
session: Record<string, State | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SavedEntry = {
|
||||||
|
saved: Saved
|
||||||
|
setSaved: ReturnType<typeof createStore<Saved>>[1]
|
||||||
|
ready: ReturnType<typeof persisted<Saved>>[3]
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const handoff = new Map<string, State>()
|
const handoff = new Map<string, State>()
|
||||||
|
|
||||||
@@ -62,20 +71,36 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||||
|
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved, , savedReady] = persisted(
|
const savedCache = new Map<string, SavedEntry>()
|
||||||
{
|
const loadSaved = (serverScope: ServerScope, directory: string) => {
|
||||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
const key = ScopedKey.from(serverScope, directory, "model-selection")
|
||||||
migrate,
|
const existing = savedCache.get(key)
|
||||||
},
|
if (existing) return existing
|
||||||
createStore<Saved>({
|
const entry = createRoot((dispose) => {
|
||||||
session: {},
|
const [saved, setSaved, , ready] = persisted(
|
||||||
}),
|
{
|
||||||
)
|
...Persist.serverWorkspace(serverScope, directory, "model-selection", ["model-selection.v1"]),
|
||||||
|
migrate,
|
||||||
|
},
|
||||||
|
createStore<Saved>({ session: {} }),
|
||||||
|
)
|
||||||
|
return { saved, setSaved, ready, dispose }
|
||||||
|
})
|
||||||
|
savedCache.set(key, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
const saved = createMemo(() => loadSaved(serverSDK().scope, sdk().directory))
|
||||||
|
onCleanup(() => {
|
||||||
|
for (const entry of savedCache.values()) entry.dispose()
|
||||||
|
savedCache.clear()
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
current?: string
|
current?: string
|
||||||
@@ -107,9 +132,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pickAgent = (name: string | undefined) => {
|
const pickAgent = (name: string | undefined) => {
|
||||||
const items = list()
|
return resolveAgent(list(), name)
|
||||||
if (items.length === 0) return
|
|
||||||
return items.find((item) => item.name === name) ?? items[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -125,7 +148,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const scope = createMemo<State | undefined>(() => {
|
const scope = createMemo<State | undefined>(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return store.draft ?? store.promoting
|
if (!session) return store.draft ?? store.promoting
|
||||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
return saved().saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -135,13 +158,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
||||||
const next = handoff.get(key)
|
const next = handoff.get(key)
|
||||||
if (!next) return
|
if (!next) return
|
||||||
if (saved.session[session] !== undefined) {
|
if (saved().saved.session[session] !== undefined) {
|
||||||
handoff.delete(key)
|
handoff.delete(key)
|
||||||
setStore("promoting", undefined)
|
setStore("promoting", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaved("session", session, clone(next))
|
saved().setSaved("session", session, clone(next))
|
||||||
handoff.delete(key)
|
handoff.delete(key)
|
||||||
setStore("promoting", undefined)
|
setStore("promoting", undefined)
|
||||||
})
|
})
|
||||||
@@ -180,8 +203,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const agent = {
|
const agent = {
|
||||||
list,
|
list,
|
||||||
|
visible: agentsVisible,
|
||||||
current() {
|
current() {
|
||||||
return pickAgent(scope()?.agent ?? store.current)
|
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
||||||
},
|
},
|
||||||
set(name: string | undefined) {
|
set(name: string | undefined) {
|
||||||
const item = pickAgent(name)
|
const item = pickAgent(name)
|
||||||
@@ -206,7 +230,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
} satisfies State
|
} satisfies State
|
||||||
const session = id()
|
const session = id()
|
||||||
if (session) {
|
if (session) {
|
||||||
setSaved("session", session, next)
|
saved().setSaved("session", session, next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("draft", next)
|
setStore("draft", next)
|
||||||
@@ -267,7 +291,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const session = id()
|
const session = id()
|
||||||
if (session) {
|
if (session) {
|
||||||
setSaved("session", session, state)
|
saved().setSaved("session", session, state)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("draft", state)
|
setStore("draft", state)
|
||||||
@@ -375,7 +399,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
session: {
|
session: {
|
||||||
ready: savedReady,
|
ready: () => saved().ready(),
|
||||||
reset() {
|
reset() {
|
||||||
setStore({ draft: undefined, promoting: undefined })
|
setStore({ draft: undefined, promoting: undefined })
|
||||||
},
|
},
|
||||||
@@ -386,7 +410,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
handoff.set(key, next)
|
handoff.set(key, next)
|
||||||
|
|
||||||
if (dir === sdk().directory) {
|
if (dir === sdk().directory) {
|
||||||
setSaved("session", session, next)
|
saved().setSaved("session", session, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("promoting", next)
|
setStore("promoting", next)
|
||||||
@@ -396,10 +420,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
if (saved.session[session] !== undefined) return
|
if (saved().saved.session[session] !== undefined) return
|
||||||
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
|
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
|
||||||
|
|
||||||
setSaved("session", session, {
|
saved().setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
model: msg.model,
|
model: msg.model,
|
||||||
variant: msg.model?.variant ?? null,
|
variant: msg.model?.variant ?? null,
|
||||||
|
|||||||
@@ -176,7 +176,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
|
|
||||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
|
|
||||||
const selected = () => ensure(activeServer())
|
const selected = () => {
|
||||||
|
const list = global.servers.list()
|
||||||
|
const key = activeServer()
|
||||||
|
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
|
||||||
|
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||||
|
if (!conn) throw new Error("Notification server not found")
|
||||||
|
return ensure(ServerConnection.key(conn))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => selected().ready(),
|
ready: () => selected().ready(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
({
|
({
|
||||||
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||||
|
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session-level override takes precedence over directory-level", () => {
|
test("session-level override takes precedence over directory-level", () => {
|
||||||
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
|
|||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("parent false override takes precedence over directory-level auto-accept", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
[`${base64Encode(directory)}/root`]: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parent true override takes precedence over disabled directory fallback", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: false,
|
||||||
|
[`${base64Encode(directory)}/root`]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("isDirectoryAutoAccepting", () => {
|
describe("isDirectoryAutoAccepting", () => {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
|
|||||||
|
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
return autoAccept[key] ?? autoAccept[sessionID]
|
||||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||||
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
|
|||||||
permission: { sessionID: string },
|
permission: { sessionID: string },
|
||||||
directory?: string,
|
directory?: string,
|
||||||
) {
|
) {
|
||||||
const value = sessionLineage(session, permission.sessionID)
|
const value = sessionAutoAccept(autoAccept, session, permission, directory)
|
||||||
|
if (value !== undefined) return value
|
||||||
|
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionAutoAccept(
|
||||||
|
autoAccept: Record<string, boolean>,
|
||||||
|
session: { id: string; parentID?: string }[],
|
||||||
|
permission: { sessionID: string },
|
||||||
|
directory?: string,
|
||||||
|
) {
|
||||||
|
return sessionLineage(session, permission.sessionID)
|
||||||
.map((id) => accepted(autoAccept, id, directory))
|
.map((id) => accepted(autoAccept, id, directory))
|
||||||
.find((item): item is boolean => item !== undefined)
|
.find((item): item is boolean => item !== undefined)
|
||||||
return value ?? false
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user