Compare commits
148 Commits
tools-menu
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -18,4 +18,4 @@ simonklee
|
||||
Slickstef11
|
||||
usrnk1
|
||||
vimtor
|
||||
starptech
|
||||
StarpTech
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
@@ -95,7 +95,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -143,7 +143,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -157,7 +157,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -220,7 +220,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -286,7 +286,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -380,7 +380,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -434,7 +434,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -448,7 +448,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -460,7 +460,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -539,7 +539,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -689,7 +689,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -706,9 +706,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.3",
|
||||
"@opentui/keymap": ">=0.4.3",
|
||||
"@opentui/solid": ">=0.4.3",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -765,7 +765,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -780,7 +780,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -795,7 +795,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -839,7 +839,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -852,7 +852,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -886,7 +886,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -905,7 +905,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -937,6 +937,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
@@ -946,7 +947,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -973,7 +974,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1024,7 +1025,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1099,9 +1100,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/keymap": "0.4.3",
|
||||
"@opentui/solid": "0.4.3",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2038,27 +2039,27 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -3844,7 +3845,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||
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", {
|
||||
hostname: domain,
|
||||
regionKey: "us",
|
||||
|
||||
+66
-33
@@ -8,6 +8,8 @@
|
||||
makeWrapper,
|
||||
writableTmpDirAsHomeHook,
|
||||
autoPatchelfHook,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
opencode,
|
||||
}:
|
||||
let
|
||||
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
nodejs
|
||||
makeWrapper
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
copyDesktopItems
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||
darwin.autoSignDarwinBinariesHook
|
||||
];
|
||||
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
(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 // {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
};
|
||||
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# mac builds use a .app bundle which doesnt have this issue
|
||||
postPatch = 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
|
||||
'';
|
||||
postPatch =
|
||||
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||
''
|
||||
substituteInPlace packages/script/src/index.ts \
|
||||
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||
''
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# 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 = ''
|
||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase =
|
||||
''
|
||||
runHook preInstall
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
mkdir -p $out/Applications
|
||||
mv dist/mac*/*.app $out/Applications
|
||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mkdir -p $out/opt/opencode-desktop
|
||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||
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
|
||||
'';
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
mkdir -p $out/Applications
|
||||
mv dist/mac*/*.app $out/Applications
|
||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mkdir -p $out/opt/opencode-desktop
|
||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||
install -Dm644 resources/icons/32x32.png \
|
||||
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||
install -Dm644 resources/icons/64x64.png \
|
||||
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||
install -Dm644 resources/icons/128x128.png \
|
||||
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
||||
install -Dm644 resources/icons/128x128@2x.png \
|
||||
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
||||
install -Dm644 resources/icons/icon.png \
|
||||
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
||||
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
||||
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||
--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 = [
|
||||
"libc.musl-x86_64.so.1"
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
||||
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
||||
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
||||
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
||||
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
|
||||
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
|
||||
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
|
||||
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -40,9 +40,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/keymap": "0.4.3",
|
||||
"@opentui/solid": "0.4.3",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -136,6 +136,9 @@ export async function setupTimeline(
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (settings.newLayoutDesigns === false) {
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
}
|
||||
}, input.settings ?? {})
|
||||
if (input.locale) {
|
||||
await page.addInitScript((locale) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
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}`)
|
||||
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 control = composer.locator('[data-component="prompt-variant-control"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await idleComposer(page)
|
||||
await expect(control).toBeHidden()
|
||||
|
||||
await composer.hover()
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
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" })
|
||||
}
|
||||
@@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
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}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: userID,
|
||||
created: 1700000001000 + index * 1_000,
|
||||
completed: index < 13,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
)
|
||||
const messages = [userMessage(), ...assistants]
|
||||
@@ -46,7 +48,7 @@ const scenarios = [
|
||||
test.use({ viewport: { width: 646, height: 1385 } })
|
||||
|
||||
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 pages: { before?: string; limit: number }[] = []
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
@@ -101,36 +103,51 @@ for (const scenario of scenarios) {
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ userPartID, lastPartID }) => {
|
||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||
;(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="${partID}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect &&
|
||||
!!view &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > view.top &&
|
||||
rect.top < view.bottom
|
||||
)
|
||||
}
|
||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||
state.samples++
|
||||
await page.addInitScript(() => {
|
||||
const visibleParts = () => {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
if (!viewport || !view) return []
|
||||
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||
.filter((part) => {
|
||||
const rect = part.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
||||
}
|
||||
const state = {
|
||||
armed: false,
|
||||
hidden: false,
|
||||
visibleParts: [] as string[],
|
||||
samples: 0,
|
||||
stop: false,
|
||||
arm() {
|
||||
state.visibleParts = visibleParts()
|
||||
state.armed = true
|
||||
},
|
||||
}
|
||||
;(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))
|
||||
},
|
||||
{ userPartID, lastPartID },
|
||||
)
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
@@ -143,23 +160,28 @@ for (const scenario of scenarios) {
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`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(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
__historyRootProbe?: { armed: boolean }
|
||||
__historyRootProbe?: { arm(): void }
|
||||
}
|
||||
).__historyRootProbe!.armed = true
|
||||
).__historyRootProbe!.arm()
|
||||
})
|
||||
await waitForProbeSamples(page, 0)
|
||||
expect(await historyRootHidden(page)).toBe(false)
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
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 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 }])
|
||||
|
||||
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(
|
||||
() => (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 }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
|
||||
const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
|
||||
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, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
await page.addInitScript(() => {
|
||||
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 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).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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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.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/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
|
||||
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(input, init)
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.20",
|
||||
"version": "1.18.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -81,7 +81,7 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||
const { term, addon } = createTerminal()
|
||||
|
||||
@@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
|
||||
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._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
||||
|
||||
|
||||
+28
-28
@@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, 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 { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
@@ -29,6 +38,7 @@ import {
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -153,8 +163,7 @@ function LegacyTargetSessionRedirect() {
|
||||
}
|
||||
|
||||
// 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/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
@@ -207,7 +216,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -215,7 +224,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</DraftServerScopedProviders>
|
||||
</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -309,24 +318,21 @@ function DesktopCommands() {
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
@@ -342,14 +348,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.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
@@ -559,13 +557,15 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
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 { useNavigate } from "@solidjs/router"
|
||||
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 { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
@@ -24,6 +25,8 @@ export type CommandPaletteEntry = {
|
||||
path?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
server?: ServerConnection.Key
|
||||
project?: LocalProject
|
||||
archived?: number
|
||||
updated?: number
|
||||
}
|
||||
@@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
||||
|
||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()()
|
||||
const serverSync = useServerSync()
|
||||
const { params, tabs } = useSessionLayout()
|
||||
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
||||
const appTabs = useTabs()
|
||||
const { tabs: sessionTabs } = useSessionLayout()
|
||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const filesOnly = () => props.filesOnly?.() ?? false
|
||||
|
||||
const allowedCommands = createMemo(() => {
|
||||
if (filesOnly()) return []
|
||||
return command.options.filter(
|
||||
(option) =>
|
||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||
)
|
||||
return commandPaletteOptions(command.options)
|
||||
})
|
||||
const commandEntries = createMemo(() => {
|
||||
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 all = allowedCommands()
|
||||
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
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 category = language.t("palette.group.commands")
|
||||
return sorted.map((option) => createCommandEntry(option, category))
|
||||
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
||||
})
|
||||
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
tabs: sessionTabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
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))
|
||||
})
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return undefined
|
||||
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||
})
|
||||
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 }),
|
||||
const sessions = createServerSessionEntries({
|
||||
server: ServerConnection.key(serverSDK.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
return
|
||||
}
|
||||
if (item.type === "session") {
|
||||
if (!item.directory || !item.sessionID) return
|
||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||
if (!item.sessionID || !item.server) return
|
||||
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
|
||||
}
|
||||
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 {
|
||||
id: "command:" + option.id,
|
||||
type: "command",
|
||||
@@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||
export function createServerSessionEntries(props: {
|
||||
server: ServerConnection.Key
|
||||
opened: () => LocalProject[]
|
||||
stored: () => Project[]
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
const state: {
|
||||
token: number
|
||||
inflight: Promise<CommandPaletteEntry[]> | undefined
|
||||
cached: CommandPaletteEntry[] | undefined
|
||||
} = { token: 0, inflight: undefined, cached: undefined }
|
||||
let abort: AbortController | undefined
|
||||
|
||||
return (text: string) => {
|
||||
if (!text.trim()) {
|
||||
state.token += 1
|
||||
state.inflight = undefined
|
||||
state.cached = undefined
|
||||
return [] as CommandPaletteEntry[]
|
||||
onCleanup(() => abort?.abort())
|
||||
|
||||
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||
const search = text.trim()
|
||||
if (!search) {
|
||||
abort?.abort()
|
||||
return []
|
||||
}
|
||||
if (state.cached) return state.cached
|
||||
if (state.inflight) return state.inflight
|
||||
|
||||
const current = state.token
|
||||
const dirs = props.workspaces()
|
||||
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
||||
|
||||
state.inflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = props.label(directory)
|
||||
return props
|
||||
.load(directory)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title ?? props.untitled(),
|
||||
description,
|
||||
directory,
|
||||
archived: session.time?.archived,
|
||||
updated: session.time?.updated,
|
||||
})),
|
||||
)
|
||||
.catch(() => [] as SessionEntryInput[])
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (state.token !== current) return [] as CommandPaletteEntry[]
|
||||
const seen = new Set<string>()
|
||||
const next = results
|
||||
.flat()
|
||||
.filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((item) => createSessionEntry(item, props.category()))
|
||||
state.cached = next
|
||||
return next
|
||||
})
|
||||
abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 100)
|
||||
current.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
if (current.signal.aborted) return []
|
||||
const opened = props.opened()
|
||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||
return props
|
||||
.load(search, current.signal)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||
return {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
||||
category: props.category(),
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
server: props.server,
|
||||
project,
|
||||
updated: session.time.updated,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
type Mem = Performance & {
|
||||
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 } = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
delay: undefined as number | undefined,
|
||||
fps: undefined as number | undefined,
|
||||
gap: undefined as number | undefined,
|
||||
focus: false,
|
||||
heap: {
|
||||
limit: 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 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 start = 0
|
||||
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide
|
||||
wide={!platform.setForceFocus}
|
||||
/>
|
||||
{platform.setForceFocus && (
|
||||
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { formatKeybindParts } from "@/context/command"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
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 { ServerConnection } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createCommandPaletteFileEntry,
|
||||
createCommandPaletteModel,
|
||||
createServerSessionEntries,
|
||||
uniqueCommandPaletteEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "./command-palette"
|
||||
@@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
||||
|
||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||
const palette = createCommandPaletteModel(props)
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [active, setActive] = createSignal(0)
|
||||
|
||||
const loadItems = async (text: string) => {
|
||||
const q = text.trim()
|
||||
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")
|
||||
return [
|
||||
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
||||
...nextSessions,
|
||||
...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.
|
||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||
const openSessions = createMemo(
|
||||
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
@@ -59,7 +152,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
palette.highlight(activeEntry())
|
||||
props.highlight(activeEntry())
|
||||
})
|
||||
|
||||
let resultsRef: HTMLDivElement | undefined
|
||||
@@ -86,12 +179,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
palette.select(activeEntry())
|
||||
props.select(activeEntry())
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
palette.close()
|
||||
props.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +198,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
appearance="large"
|
||||
placeholder={palette.language.t("palette.search.placeholder")}
|
||||
placeholder={props.placeholder}
|
||||
leadingIcon={<Icon name="magnifying-glass" />}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -117,7 +210,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
@@ -132,9 +225,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
||||
<PaletteRow
|
||||
item={item}
|
||||
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))}
|
||||
onSelect={() => palette.select(item)}
|
||||
onSelect={() => props.select(item)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -153,13 +251,19 @@ function PaletteRow(props: {
|
||||
item: CommandPaletteEntry
|
||||
active: boolean
|
||||
language: ReturnType<typeof useLanguage>
|
||||
sessionOpen: boolean
|
||||
onActive: () => 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 (
|
||||
<button
|
||||
type="button"
|
||||
class="command-palette-v2-row"
|
||||
class="command-palette-v2-row group"
|
||||
role="option"
|
||||
aria-selected={props.active}
|
||||
data-active={props.active ? "" : undefined}
|
||||
@@ -197,7 +301,25 @@ function PaletteRow(props: {
|
||||
</Match>
|
||||
<Match when={props.item.type === "session"}>
|
||||
<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">
|
||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
{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 { Tag } from "@opencode-ai/ui/tag"
|
||||
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 {
|
||||
type Accessor,
|
||||
@@ -16,6 +19,8 @@ import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
@@ -27,6 +32,7 @@ import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
|
||||
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
|
||||
const fallback = useProviderConnectController()
|
||||
const controller = props.controller ?? fallback
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const reset = controller.back
|
||||
const back = { current: reset }
|
||||
let focusHost: HTMLDivElement | undefined
|
||||
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
||||
const select = (provider?: string) => {
|
||||
back.current = reset
|
||||
controller.select(provider)
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
>
|
||||
function Content() {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={controller.selected() === CUSTOM_ID}>
|
||||
<CustomProviderForm />
|
||||
<CustomProviderForm autofocus={!newLayout()} />
|
||||
</Match>
|
||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||
{(provider) => (
|
||||
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
||||
<ProviderPicker
|
||||
directory={props.directory}
|
||||
onSelect={select}
|
||||
onPrepare={newLayout() ? holdFocus : undefined}
|
||||
/>
|
||||
</Match>
|
||||
</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 language = useLanguage()
|
||||
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: {
|
||||
provider: string
|
||||
directory?: Accessor<string | undefined>
|
||||
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const alive = { value: true }
|
||||
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
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({
|
||||
methodIndex: undefined as undefined | number,
|
||||
methodIndex: directMethod as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
|
||||
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 {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = (value as { data?: { message?: unknown } }).data
|
||||
@@ -519,6 +762,37 @@ function ProviderConnection(props: {
|
||||
props.setBack(goBack)
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div class="text-14-regular text-text-base">
|
||||
@@ -552,11 +826,18 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function ApiAuthView() {
|
||||
let apiKey: HTMLInputElement | undefined
|
||||
const errorID = createUniqueId()
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!newLayout()) return
|
||||
apiKey?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
|
||||
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 (
|
||||
<div class="flex flex-col gap-6">
|
||||
<Switch>
|
||||
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
|
||||
</Switch>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
autofocus={!newLayout()}
|
||||
ref={apiKey}
|
||||
type="text"
|
||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
@@ -624,11 +958,18 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function OAuthCodeView() {
|
||||
let codeInput: HTMLInputElement | undefined
|
||||
const errorID = createUniqueId()
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!newLayout()) return
|
||||
codeInput?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -657,6 +998,46 @@ function ProviderConnection(props: {
|
||||
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 (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
autofocus={!newLayout()}
|
||||
ref={codeInput}
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
@@ -738,10 +1120,19 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
||||
<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={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>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
@@ -750,8 +1141,12 @@ function ProviderConnection(props: {
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<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={newLayout() ? undefined : 0}
|
||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<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 serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -192,7 +192,7 @@ export function CustomProviderForm() {
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
autofocus={props.autofocus ?? true}
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
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 { Icon } from "@opencode-ai/ui/v2/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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
|
||||
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) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
const freeModels = createMemo(() => model.list().filter(isFree))
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
@@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
})
|
||||
|
||||
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")}>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
||||
<ScrollView class="min-h-0 flex-1 w-full">
|
||||
<div ref={listEl} class="flex min-h-full flex-col">
|
||||
<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-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
</div>
|
||||
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||
<div class="flex w-full flex-col items-start pb-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 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.freeModels.title")}
|
||||
</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 class="flex w-full flex-col p-2.5 pt-0">
|
||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
||||
<div class="flex h-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]">
|
||||
{language.t("dialog.model.unpaid.addMore.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<For
|
||||
each={[...providers.popular()].sort((a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
}
|
||||
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>
|
||||
<For each={freeModels()}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={{ ...item, name: displayModelName(item.name) }}
|
||||
latest={item.latest}
|
||||
free={isFree(item)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
onClick={() => openProviders()}
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="dot-grid" size="small" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
<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 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 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>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
|
||||
@@ -1,79 +1,35 @@
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
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
|
||||
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
|
||||
export function TabsInfoPopup() {
|
||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||
|
||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
||||
// setState({ dismissed: false }) // for testing
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={!state.dismissed}>
|
||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||
<div
|
||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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)]"
|
||||
onClick={() => setState("dismissed", true)}
|
||||
onClick={settings.general.dismissTabsToast}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -90,7 +46,7 @@ export function TabsInfoPopup() {
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
setState("dismissed", true)
|
||||
settings.general.dismissTabsToast()
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
@@ -115,19 +71,49 @@ export function TabsInfoPopup() {
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<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">
|
||||
<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">
|
||||
June 16
|
||||
</p>
|
||||
<DrawerContent
|
||||
style={
|
||||
windows()
|
||||
? {
|
||||
inset: "0 0 0 auto",
|
||||
"max-height": "100vh",
|
||||
"max-width": "100vw",
|
||||
"border-radius": "0",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show when={windows()}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
variant="neutral"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
class="absolute top-[10px] left-[-36px]"
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
||||
classList={{
|
||||
"h-[40px] px-4": windows(),
|
||||
"h-[52px] p-4": !windows(),
|
||||
}}
|
||||
>
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
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 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">
|
||||
@@ -151,7 +137,7 @@ export function TabsInfoPopup() {
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -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.client}:${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.client,
|
||||
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,
|
||||
reviewOpen: false,
|
||||
})
|
||||
const storyModel = {
|
||||
id: "claude-3-7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: { id: "anthropic", name: "Anthropic" },
|
||||
}
|
||||
const model = {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
current: () => storyModel,
|
||||
list: () => [storyModel],
|
||||
visible: () => true,
|
||||
set: () => {},
|
||||
variant: {
|
||||
list: () => ["fast", "thinking"],
|
||||
current: () => controls.variant,
|
||||
@@ -67,7 +75,6 @@ function PromptInputExample() {
|
||||
open: () => setControls("reviewOpen", true),
|
||||
},
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
@@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
Match,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
ContentPart,
|
||||
@@ -49,7 +47,6 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -60,121 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryEntry,
|
||||
type PromptHistoryStoredEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||
import {
|
||||
createPersistedPromptInputHistory,
|
||||
createPromptInputHistory,
|
||||
type PromptInputHistory,
|
||||
} from "./prompt-input/history-store"
|
||||
import {
|
||||
type PromptInputControls,
|
||||
type PromptInputProps,
|
||||
type PromptInputState,
|
||||
type PromptInputSubmission,
|
||||
} from "./prompt-input/contracts"
|
||||
import { createPromptSubmit } from "./prompt-input/submit"
|
||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
export type PromptInputSubmission = {
|
||||
abort: () => Promise<void> | void
|
||||
handleSubmit: (event: Event) => Promise<void> | void
|
||||
}
|
||||
|
||||
export type PromptInputControls = {
|
||||
agents: {
|
||||
available: { name: string; hidden?: boolean; mode: string }[]
|
||||
options: string[]
|
||||
current: string
|
||||
loading: boolean
|
||||
visible: boolean
|
||||
select: (name: string | undefined) => void
|
||||
}
|
||||
model: {
|
||||
selection: ReturnType<typeof useLocal>["model"]
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
}
|
||||
session: {
|
||||
id?: string
|
||||
tabs: {
|
||||
active: () => string | undefined
|
||||
all: () => string[]
|
||||
open: (tab: string) => void | Promise<void>
|
||||
setActive: (tab: string) => void
|
||||
}
|
||||
reviewPanel: {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}
|
||||
newLayoutDesigns: boolean
|
||||
}
|
||||
|
||||
export function createPromptInputHistory(): PromptInputHistory {
|
||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): PromptInputHistory {
|
||||
return {
|
||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||
add(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = prependHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
state?: PromptInputState
|
||||
history?: PromptInputHistory
|
||||
submission?: PromptInputSubmission
|
||||
controls: PromptInputControls
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
shouldQueue?: () => boolean
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
toolbar?: JSX.Element
|
||||
}
|
||||
export { createPromptInputHistory }
|
||||
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||
|
||||
const EXAMPLES = [
|
||||
"prompt.example.1",
|
||||
@@ -1191,28 +1101,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const openCommands = () => {
|
||||
const populated = prompt.dirty() || commentCount() > 0
|
||||
requestAnimationFrame(() => {
|
||||
if (!populated) {
|
||||
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
|
||||
return
|
||||
}
|
||||
slashOnInput("")
|
||||
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const openContext = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
|
||||
atOnInput("")
|
||||
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
|
||||
})
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||
}
|
||||
@@ -1539,50 +1427,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
(p) => p,
|
||||
)
|
||||
|
||||
const designPlaceholder = () => {
|
||||
if (store.mode === "shell") return placeholder()
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
}
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const bindEditorRef = (el: HTMLDivElement) => {
|
||||
editorRef = el
|
||||
restoreEndOnFocus = true
|
||||
props.ref?.(el)
|
||||
}
|
||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybindParts("agent.cycle"),
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
onSelect: (value) => {
|
||||
props.controls.agents.select(value)
|
||||
restoreFocus()
|
||||
},
|
||||
}))
|
||||
return (
|
||||
<div class="relative size-full flex flex-col gap-0">
|
||||
{(promptReady(), null)}
|
||||
@@ -1607,761 +1456,335 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSlashMenuKeyDown={handleSlashMenuKeyDown}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
newLayoutDesigns={false}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={props.controls.newLayoutDesigns}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(
|
||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
||||
)}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems().filter((item) => !isCommentItem(item))}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
openComment={openComment}
|
||||
remove={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
comments={contextItems().filter(isCommentItem)}
|
||||
commentActive={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
onOpenComment={openComment}
|
||||
onRemoveComment={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="relative min-h-[52px]"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action^="prompt-"]')) return
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={designPlaceholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
|
||||
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
|
||||
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
|
||||
>
|
||||
{designPlaceholder()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
{fileAttachmentInput()}
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.menu.addImagesAndFiles")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon={<IconV2 name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
style={buttons()}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content style={{ "min-width": "180px" }}>
|
||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
||||
{language.t("prompt.menu.imagesAndFiles")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={openCommands} shortcut="/">
|
||||
{language.t("prompt.menu.commands")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={openContext} shortcut="@">
|
||||
{language.t("prompt.menu.context")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
|
||||
{language.t("prompt.menu.shellCommand")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{language.t("command.model.variant.cycle")}
|
||||
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-model-variant"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate leading-5">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.RadioGroup
|
||||
value={props.controls.model.selection.variant.current() ?? "default"}
|
||||
onChange={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
>
|
||||
{variants().map((value) => (
|
||||
<MenuV2.RadioItem value={value} class="capitalize">
|
||||
{value === "default" ? language.t("common.default") : value}
|
||||
</MenuV2.RadioItem>
|
||||
))}
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when>
|
||||
<DockShellForm
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
<DockShellForm
|
||||
data-dock-border-underlay="legacy"
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
openComment={openComment}
|
||||
remove={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
newLayoutDesigns={false}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
newLayoutDesigns={false}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||
return
|
||||
}
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
style={{ "scroll-padding-bottom": space }}
|
||||
>
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(
|
||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
||||
)}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
openComment={openComment}
|
||||
remove={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
style={{ "padding-bottom": space }}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||
return
|
||||
}
|
||||
editorRef?.focus()
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
height: space,
|
||||
background:
|
||||
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="pointer-events-auto"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
style={{ "scroll-padding-bottom": space }}
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
style={{ "padding-bottom": space }}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
|
||||
<Button
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
height: space,
|
||||
background:
|
||||
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="pointer-events-auto"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
</div>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
|
||||
style={{
|
||||
padding: "0 0px 0 8px",
|
||||
...shell(),
|
||||
}}
|
||||
>
|
||||
<Icon name="console" />
|
||||
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base"
|
||||
onClick={() => {
|
||||
setStore("mode", "normal")
|
||||
}}
|
||||
>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
|
||||
style={{
|
||||
padding: "0 0px 0 8px",
|
||||
...shell(),
|
||||
}}
|
||||
data-component="prompt-agent-control"
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
>
|
||||
<Icon name="console" />
|
||||
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base"
|
||||
onClick={() => {
|
||||
setStore("mode", "normal")
|
||||
}}
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.controls.agents.options}
|
||||
current={props.controls.agents.current}
|
||||
onSelect={(value) => {
|
||||
props.controls.agents.select(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
</Show>
|
||||
<Show when={!providersLoading()}>
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
}}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.controls.model.selection}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.controls.agents.options}
|
||||
current={props.controls.agents.current}
|
||||
options={variants()}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
props.controls.agents.select(value)
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!providersLoading()}>
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
dialog.show(() => (
|
||||
<DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
}}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.controls.model.selection}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ComposerAgentControlState = {
|
||||
title: string
|
||||
keybind: string[]
|
||||
options: string[]
|
||||
current: string
|
||||
style: JSX.CSSProperties | undefined
|
||||
onSelect: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
type ComposerModelControlState = {
|
||||
loading: boolean
|
||||
shouldAnimate: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string[]
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
}
|
||||
|
||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
return (
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
current={props.state.current}
|
||||
onSelect={props.state.onSelect}
|
||||
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={props.state.style}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
return (
|
||||
<Show when={!props.state.loading}>
|
||||
<Show
|
||||
when={props.state.paid}
|
||||
fallback={
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} />
|
||||
</ModelSelectorPopover>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.state.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate leading-4">{props.state.modelName}</span>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
export type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
|
||||
@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
|
||||
}
|
||||
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||
? [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
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")
|
||||
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 type { Prompt } from "@/context/prompt"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -27,9 +28,17 @@ let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const prompt = {
|
||||
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
@@ -56,6 +65,7 @@ const clientFor = (directory: string) => {
|
||||
return {
|
||||
session: {
|
||||
create: async () => {
|
||||
await createSessionGate
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
data: {
|
||||
@@ -122,13 +132,14 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => ({
|
||||
usePermission: () => ({
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ sessionID, directory })
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
@@ -251,6 +262,8 @@ beforeEach(() => {
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
@@ -318,7 +331,40 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
|
||||
@@ -12,7 +12,6 @@ export type PromptInputTransientState = {
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}
|
||||
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
@@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
|
||||
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 { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -46,7 +55,7 @@ export function createPromptProjectController(input: {
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const selected = () => {
|
||||
const current = () => {
|
||||
const key = pathKey(input.controls().directory)
|
||||
return input
|
||||
.controls()
|
||||
@@ -56,6 +65,7 @@ export function createPromptProjectController(input: {
|
||||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||
)
|
||||
}
|
||||
const selected = () => current() ?? input.controls().available[0]
|
||||
const projects = () => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
if (!search) return input.controls().available
|
||||
@@ -91,8 +101,8 @@ export function createPromptProjectController(input: {
|
||||
}
|
||||
const select = (project: PromptProject) => {
|
||||
if (
|
||||
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
||||
project.server?.key !== selected()?.server?.key
|
||||
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
|
||||
project.server?.key !== current()?.server?.key
|
||||
) {
|
||||
input.controls().select(project.worktree, project.server?.key)
|
||||
}
|
||||
@@ -115,6 +125,7 @@ export function createPromptProjectController(input: {
|
||||
|
||||
return {
|
||||
selected,
|
||||
empty: () => input.controls().available.length === 0,
|
||||
projects,
|
||||
servers,
|
||||
projectKey,
|
||||
@@ -184,9 +195,28 @@ export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let triggerFrame: number | undefined
|
||||
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 = () =>
|
||||
props.controller.active()
|
||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||
@@ -257,13 +287,13 @@ export function PromptProjectSelector(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
open={triggerReady() && props.controller.open()}
|
||||
placement={props.placement ?? "bottom"}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
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.Content
|
||||
ref={contentRef}
|
||||
|
||||
@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<Show when={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>
|
||||
<PromptGitStatus branch={props.branch} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
return props.branch
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[data-component="server-status-icon"] {
|
||||
color: var(--v2-icon-icon-muted);
|
||||
|
||||
[data-slot="server-status-left"],
|
||||
[data-slot="server-status-right"] {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
[data-slot="server-status-dot"] {
|
||||
fill: var(--server-status-color);
|
||||
}
|
||||
|
||||
&[data-state="reconnecting"] {
|
||||
--server-status-color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
&[data-state="disconnected"] {
|
||||
--server-status-color: var(--v2-state-fg-danger);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { ServerStatusIcon, type ServerStatusIconState } from "./server-status-icon"
|
||||
|
||||
export default {
|
||||
title: "Desktop V2/Server Status Icon",
|
||||
id: "desktop-v2-server-status-icon",
|
||||
component: ServerStatusIcon,
|
||||
tags: ["autodocs"],
|
||||
}
|
||||
|
||||
function Preview(props: { state: ServerStatusIconState; label: string }) {
|
||||
return (
|
||||
<div class="flex items-center gap-3">
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
aria-label={props.label}
|
||||
icon={<ServerStatusIcon state={props.state} />}
|
||||
/>
|
||||
<span class="text-[13px] text-v2-text-text-muted">{props.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Reconnecting = {
|
||||
render: () => <Preview state="reconnecting" label="Retrying automatically..." />,
|
||||
}
|
||||
|
||||
export const Disconnected = {
|
||||
render: () => <Preview state="disconnected" label="Server disconnected" />,
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveServerStatus } from "./server-status-icon"
|
||||
|
||||
describe("resolveServerStatus", () => {
|
||||
test("prioritizes a disconnected server over stream reconnection", () => {
|
||||
expect(resolveServerStatus(false, "reconnecting")).toBe("disconnected")
|
||||
})
|
||||
|
||||
test("shows reconnection while the server remains reachable", () => {
|
||||
expect(resolveServerStatus(true, "reconnecting")).toBe("reconnecting")
|
||||
})
|
||||
|
||||
test("stays hidden for healthy and initial connections", () => {
|
||||
expect(resolveServerStatus(true, "connected")).toBeUndefined()
|
||||
expect(resolveServerStatus(undefined, "connecting")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import "./server-status-icon.css"
|
||||
|
||||
export type ServerStatusIconState = "reconnecting" | "disconnected"
|
||||
export type ServerConnectionState = "connecting" | "connected" | "reconnecting"
|
||||
|
||||
export function resolveServerStatus(healthy: boolean | undefined, connection: ServerConnectionState) {
|
||||
if (healthy === false) return "disconnected" as const
|
||||
if (connection === "reconnecting") return "reconnecting" as const
|
||||
}
|
||||
|
||||
export function ServerStatusIcon(props: { state: ServerStatusIconState }) {
|
||||
return (
|
||||
<svg
|
||||
data-component="server-status-icon"
|
||||
data-state={props.state}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M13.3141 6.00001V14.4572H2.68555V1.54285H9.5M13.3141 14.4572V9.54285H2.68555V14.4572"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path data-slot="server-status-left" d="M8.5 11.75H8V12.25H8.5Z" />
|
||||
<path data-slot="server-status-right" d="M11 11.75H10.5V12.25H11Z" />
|
||||
<path
|
||||
data-slot="server-status-dot"
|
||||
d="M13 5C14.3807 5 15.5 3.88071 15.5 2.5C15.5 1.11929 14.3807 0 13 0C11.6193 0 10.5 1.11929 10.5 2.5C10.5 3.88071 11.6193 5 13 5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @refresh reload
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -17,9 +16,7 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
@@ -36,8 +33,6 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
import { useTitlebarRightMount } from "../titlebar"
|
||||
import { useSettingsDialog } from "../settings-dialog"
|
||||
import { resolveServerStatus, ServerStatusIcon, type ServerStatusIconState } from "../server-status-icon"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -149,8 +144,6 @@ export function SessionHeader() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const global = useGlobal()
|
||||
const serverSDK = useServerSDK()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
@@ -172,7 +165,6 @@ export function SessionHeader() {
|
||||
const search = settings.visibility.search
|
||||
const status = settings.visibility.status
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const openServerSettings = useSettingsDialog("servers")
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -242,25 +234,9 @@ export function SessionHeader() {
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||
)
|
||||
const serverStatus = createMemo<ServerStatusIconState | undefined>(() => {
|
||||
const current = serverSDK()
|
||||
return resolveServerStatus(
|
||||
global.servers.health[ServerConnection.key(current.server)]?.healthy,
|
||||
current.connection.status,
|
||||
)
|
||||
})
|
||||
const serverStatusLabel = createMemo(() => {
|
||||
if (serverStatus() === "disconnected") {
|
||||
return language.t("app.server.status.disconnected", { server: serverName(serverSDK().server) })
|
||||
}
|
||||
return language.t("app.server.status.reconnecting", { server: serverName(serverSDK().server) })
|
||||
})
|
||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||
serverStatus: isDesktop() ? serverStatus() : undefined,
|
||||
serverStatusLabel: serverStatusLabel(),
|
||||
onServerStatusClick: openServerSettings,
|
||||
toolsVisible: isDesktop() && status(),
|
||||
statusLabel: language.t("status.popover.tools.trigger"),
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
@@ -541,10 +517,7 @@ export function SessionHeader() {
|
||||
}
|
||||
|
||||
type SessionHeaderV2ActionsState = {
|
||||
serverStatus?: ServerStatusIconState
|
||||
serverStatusLabel: string
|
||||
onServerStatusClick: () => void
|
||||
toolsVisible: boolean
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string[]
|
||||
@@ -557,26 +530,11 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-[6px]">
|
||||
<Show when={props.state.serverStatus}>
|
||||
{(status) => (
|
||||
<TooltipV2 placement="bottom" value={props.state.serverStatusLabel}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
onClick={props.state.onServerStatusClick}
|
||||
aria-label={props.state.serverStatusLabel}
|
||||
icon={<ServerStatusIcon state={status()} />}
|
||||
/>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.state.toolsVisible}>
|
||||
<TooltipV2 placement="bottom" value={props.state.statusLabel}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopoverV2 />
|
||||
</TooltipV2>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
|
||||
@@ -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 class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
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 { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -19,6 +20,7 @@ export function SortableTabV2(props: {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
@@ -39,9 +41,15 @@ export function SortableTabV2(props: {
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
@@ -52,7 +60,7 @@ export function SortableTabV2(props: {
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
|
||||
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
terminal.requestFocus(props.terminal.id)
|
||||
terminal.open(props.terminal.id)
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
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) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
|
||||
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 = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
@@ -334,24 +379,6 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
@@ -718,6 +745,14 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<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 { showToast } from "@/utils/toast"
|
||||
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 { useSettings } from "@/context/settings"
|
||||
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 PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
|
||||
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 { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import "./settings-v2.css"
|
||||
|
||||
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 = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
</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"}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
@@ -377,8 +385,8 @@ export const SettingsGeneralV2: Component<{
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showTools.title")}
|
||||
description={language.t("settings.general.row.showTools.description")}
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
.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"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -128,9 +133,10 @@
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
font-size: 11px;
|
||||
margin-block: -3.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
line-height: 20px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@@ -265,10 +271,10 @@
|
||||
}
|
||||
|
||||
.settings-v2-provider-description {
|
||||
margin: 0;
|
||||
margin-block: -3.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
line-height: 20px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@@ -366,7 +372,7 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-models {
|
||||
.settings-v2-models {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { ToolsMenu } from "./tools-menu"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -281,14 +280,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const configuredMcpNames = createMemo(() =>
|
||||
Object.keys(sync().data.config.mcp ?? {}).sort((a, b) => a.localeCompare(b)),
|
||||
)
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
const mcpError = (name: string) => {
|
||||
const status = sync().data.mcp?.[name]
|
||||
if (status?.status === "failed" || status?.status === "needs_client_registration") return status.error
|
||||
}
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
@@ -297,42 +289,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
if (settings.general.newLayoutDesigns()) {
|
||||
return (
|
||||
<ToolsMenu
|
||||
labels={{
|
||||
menu: language.t("status.popover.tools.trigger"),
|
||||
mcp: language.t("status.popover.tab.mcp"),
|
||||
lsp: language.t("status.popover.tab.lsp"),
|
||||
plugins: language.t("status.popover.tab.plugins"),
|
||||
mcpDescription: language.t("status.popover.tools.mcp.description"),
|
||||
lspDescription: language.t("status.popover.tools.lsp.description"),
|
||||
pluginsDescription: language.t("status.popover.tools.plugins.description"),
|
||||
disabled: language.t("mcp.status.disabled"),
|
||||
failed: language.t("mcp.status.failed"),
|
||||
reauthenticate: language.t("status.popover.tools.reauthenticate"),
|
||||
}}
|
||||
empty={{
|
||||
mcp: language.t("status.popover.tools.mcp.empty"),
|
||||
lsp: language.t("status.popover.tools.lsp.empty"),
|
||||
plugins: language.t("status.popover.tools.plugins.empty"),
|
||||
}}
|
||||
mcp={configuredMcpNames().map((name) => ({
|
||||
name,
|
||||
status: mcpStatus(name),
|
||||
error: mcpError(name),
|
||||
loading: !mcpStatus(name),
|
||||
pending: toggleMcp.isPending && toggleMcp.variables === name,
|
||||
onToggle: () => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
},
|
||||
}))}
|
||||
lsp={lspItems().map((item) => ({ name: item.name || item.id, status: item.status }))}
|
||||
plugins={plugins()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @refresh reload
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -9,7 +9,6 @@ import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ToolsMenuIcon } from "./tools-menu"
|
||||
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
@@ -88,10 +87,7 @@ function DirectoryStatusPopover() {
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.keys(sync().data.config.mcp ?? {}).flatMap((name) => {
|
||||
const status = sync().data.mcp?.[name]
|
||||
return status ? [status.status] : []
|
||||
}),
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
}),
|
||||
)
|
||||
@@ -100,7 +96,7 @@ function DirectoryStatusPopover() {
|
||||
ready: ready(),
|
||||
serverHealth: serverHealth(),
|
||||
issue: issue(),
|
||||
label: language.t("status.popover.tools.trigger"),
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
@@ -178,7 +174,14 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
trigger={<ToolsMenuIcon warning={props.state.issue} />}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
{props.state.body()}
|
||||
|
||||
@@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
@@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
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 restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
@@ -283,7 +292,12 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const scheduleSize = (cols: number, rows: number) => {
|
||||
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 }
|
||||
|
||||
@@ -308,8 +322,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
createEffect(() => {
|
||||
const colors = terminalColors()
|
||||
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||
if (!term) return
|
||||
setOptionIfSupported(term, "theme", colors)
|
||||
setOptionIfSupported(term, "colorScheme", mode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -387,6 +403,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
_ghostty = g
|
||||
term = t
|
||||
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
done?.()
|
||||
@@ -416,6 +433,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
fitAddon = fit
|
||||
serializeAddon = serializer
|
||||
|
||||
const active = document.activeElement
|
||||
t.open(container)
|
||||
useTerminalUiBindings({
|
||||
container,
|
||||
@@ -425,7 +443,22 @@ export const Terminal = (props: TerminalProps) => {
|
||||
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) {
|
||||
void document.fonts.ready.then(scheduleFit)
|
||||
@@ -570,6 +603,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
tries = 0
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (t.getMode(2031)) t.write("\x1b[?996n")
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
|
||||
return mount
|
||||
}
|
||||
|
||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: "tab.new",
|
||||
category: "tab",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+t",
|
||||
keybind: "mod+t,mod+n",
|
||||
hidden: true,
|
||||
onSelect: openNewTab,
|
||||
},
|
||||
@@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
"md:pl-4": !mac(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator />
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||
</Show>
|
||||
@@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
</div>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<ChannelIndicator />
|
||||
</div>
|
||||
</div>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</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 (
|
||||
<>
|
||||
{["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">
|
||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { For, type JSXElement } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ToolsMenu, ToolsMenuIcon, type ToolsMenuProps, type ToolsMenuStatus, type ToolsMenuTab } from "./tools-menu"
|
||||
|
||||
export default {
|
||||
title: "Desktop V2/Tools Menu",
|
||||
id: "desktop-v2-tools-menu",
|
||||
component: ToolsMenu,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `The desktop V2 Tools menu shown in the titlebar. Stories cover every MCP status, both LSP states, populated plugin rows, and empty states for all tabs.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const labels = {
|
||||
menu: "Tools",
|
||||
mcp: "MCP",
|
||||
lsp: "LSP",
|
||||
plugins: "Plugins",
|
||||
mcpDescription: "Add MCP servers in opencode.json",
|
||||
lspDescription: "LSPs are auto-detected as you open files",
|
||||
pluginsDescription: "Add plugins in opencode.json",
|
||||
disabled: "Disabled",
|
||||
failed: "Failed",
|
||||
reauthenticate: "Reauthenticate",
|
||||
}
|
||||
|
||||
const empty = {
|
||||
mcp: "No MCPs configured yet",
|
||||
lsp: "No active language servers yet",
|
||||
plugins: "No plugins configured yet",
|
||||
}
|
||||
|
||||
const mcp = [
|
||||
{ name: "ui-docs", status: "needs_auth" },
|
||||
{ name: "browser-tools", status: "needs_client_registration" },
|
||||
{ name: "brave-search", status: "disabled" },
|
||||
{ name: "fastmail-mcp", status: "connected" },
|
||||
{ name: "puppeteer", status: "failed" },
|
||||
{ name: "figma", status: "connected" },
|
||||
{ name: "playwright", status: "failed" },
|
||||
] satisfies Array<{ name: string; status: ToolsMenuStatus }>
|
||||
|
||||
const lsp = [
|
||||
{ name: "typescript", status: "connected" },
|
||||
{ name: "eslint", status: "connected" },
|
||||
{ name: "rust-analyzer", status: "error" },
|
||||
] satisfies ToolsMenuProps["lsp"]
|
||||
|
||||
const plugins = ["opencode-devcontainers", "opencode-daytona", "~/.config/opencode/plugins/ping.ts"]
|
||||
|
||||
const populated = {
|
||||
labels,
|
||||
empty,
|
||||
mcp,
|
||||
lsp,
|
||||
plugins,
|
||||
} satisfies Omit<ToolsMenuProps, "defaultTab">
|
||||
|
||||
function InteractiveMenu() {
|
||||
const [state, setState] = createStore({ mcp: mcp.map((item) => ({ ...item, pending: false })) })
|
||||
return (
|
||||
<MenuPreview warning>
|
||||
<ToolsMenu
|
||||
{...populated}
|
||||
mcp={state.mcp.map((item, index) => ({
|
||||
...item,
|
||||
onToggle: () => {
|
||||
if (item.status === "needs_auth" || item.status === "needs_client_registration") return
|
||||
setState("mcp", index, "status", item.status === "connected" ? "disabled" : "connected")
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</MenuPreview>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuPreview(props: { warning?: boolean; children: JSXElement }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex h-7 items-center gap-2 text-[11px] font-[530] uppercase tracking-[0.05px] text-v2-text-text-faint">
|
||||
<ToolsMenuIcon warning={props.warning} />
|
||||
{props.warning ? "warning" : "normal"}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Gallery(props: { empty?: boolean }) {
|
||||
const tabs: ToolsMenuTab[] = ["mcp", "lsp", "plugins"]
|
||||
return (
|
||||
<div class="flex flex-wrap items-start gap-6">
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<MenuPreview warning={!props.empty && tab !== "plugins"}>
|
||||
<ToolsMenu
|
||||
{...populated}
|
||||
defaultTab={tab}
|
||||
mcp={props.empty ? [] : populated.mcp}
|
||||
lsp={props.empty ? [] : populated.lsp}
|
||||
plugins={props.empty ? [] : populated.plugins}
|
||||
/>
|
||||
</MenuPreview>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive = {
|
||||
render: () => <InteractiveMenu />,
|
||||
}
|
||||
|
||||
export const AllTabs = {
|
||||
name: "All populated tabs",
|
||||
render: () => <Gallery />,
|
||||
}
|
||||
|
||||
export const EmptyStates = {
|
||||
name: "All empty tabs",
|
||||
render: () => <Gallery empty />,
|
||||
}
|
||||
|
||||
export const McpPending = {
|
||||
name: "MCP pending toggle",
|
||||
render: () => (
|
||||
<MenuPreview warning>
|
||||
<ToolsMenu {...populated} mcp={populated.mcp.map((item) => ({ ...item, pending: item.name === "figma" }))} />
|
||||
</MenuPreview>
|
||||
),
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { For, Index, Show, type JSXElement } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type ToolsMenuTab = "mcp" | "lsp" | "plugins"
|
||||
export type ToolsMenuStatus = "connected" | "failed" | "error" | "needs_auth" | "needs_client_registration" | "disabled"
|
||||
|
||||
export type ToolsMenuProps = {
|
||||
defaultTab?: ToolsMenuTab
|
||||
labels: {
|
||||
menu: string
|
||||
mcp: string
|
||||
lsp: string
|
||||
plugins: string
|
||||
mcpDescription: string
|
||||
lspDescription: string
|
||||
pluginsDescription: string
|
||||
disabled: string
|
||||
failed: string
|
||||
reauthenticate: string
|
||||
}
|
||||
empty: {
|
||||
mcp: JSXElement
|
||||
lsp: JSXElement
|
||||
plugins: JSXElement
|
||||
}
|
||||
mcp: Array<{
|
||||
name: string
|
||||
status?: ToolsMenuStatus
|
||||
error?: string
|
||||
loading?: boolean
|
||||
pending?: boolean
|
||||
onToggle?: () => void
|
||||
}>
|
||||
lsp: Array<{
|
||||
name: string
|
||||
status: "connected" | "error"
|
||||
}>
|
||||
plugins: string[]
|
||||
}
|
||||
|
||||
export function ToolsMenuIcon(props: { warning?: boolean }) {
|
||||
return (
|
||||
<div class="relative size-4 text-v2-icon-icon-muted">
|
||||
<Icon name={props.warning ? "tools-warning" : "tools"} />
|
||||
<Show when={props.warning}>
|
||||
<div class="absolute right-0 top-0 size-[5px] rounded-full bg-icon-warning-base" />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsMenu(props: ToolsMenuProps) {
|
||||
const [state, setState] = createStore({ tab: props.defaultTab ?? ("mcp" as ToolsMenuTab) })
|
||||
|
||||
return (
|
||||
<div class="w-[360px] max-w-[calc(100vw-40px)] rounded-xl bg-v2-background-bg-base p-4 [font-family:var(--v2-font-family-sans)] shadow-[var(--v2-elevation-floating)]">
|
||||
<SegmentedControlV2
|
||||
value={state.tab}
|
||||
onChange={(value) => value && setState("tab", value as ToolsMenuTab)}
|
||||
class="segmented-control-v2--full-width"
|
||||
aria-label={props.labels.menu}
|
||||
>
|
||||
<SegmentedControlItemV2 value="mcp">{props.labels.mcp}</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="lsp">{props.labels.lsp}</SegmentedControlItemV2>
|
||||
<SegmentedControlItemV2 value="plugins">{props.labels.plugins}</SegmentedControlItemV2>
|
||||
</SegmentedControlV2>
|
||||
|
||||
<Show when={state.tab === "mcp"}>
|
||||
<Show
|
||||
when={props.mcp.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.mcp} description={props.labels.mcpDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.mcpDescription}>
|
||||
<Index each={props.mcp}>
|
||||
{(item) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status={item().status} />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{item().name}
|
||||
</span>
|
||||
<Show
|
||||
when={item().status === "needs_auth"}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={item().status === "needs_client_registration"}>
|
||||
<span
|
||||
class="max-w-40 truncate text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint"
|
||||
title={item().error}
|
||||
>
|
||||
{item().error ?? props.labels.failed}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item().status !== "needs_client_registration"}>
|
||||
<Show when={item().status === "disabled" || item().status === "failed"}>
|
||||
<span class="text-[11px] font-[440] capitalize leading-4 tracking-[0.05px] text-v2-text-text-faint">
|
||||
{item().status === "disabled" ? props.labels.disabled : props.labels.failed}
|
||||
</span>
|
||||
</Show>
|
||||
<Switch
|
||||
checked={item().status === "connected"}
|
||||
disabled={item().loading}
|
||||
hideLabel
|
||||
onChange={item().onToggle}
|
||||
readOnly={item().pending}
|
||||
>
|
||||
{item().name}
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="small" variant="outline" disabled={item().pending} onClick={item().onToggle}>
|
||||
{props.labels.reauthenticate}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.tab === "lsp"}>
|
||||
<Show
|
||||
when={props.lsp.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.lsp} description={props.labels.lspDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.lspDescription}>
|
||||
<For each={props.lsp}>
|
||||
{(item) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status={item.status} />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.tab === "plugins"}>
|
||||
<Show
|
||||
when={props.plugins.length > 0}
|
||||
fallback={<ToolsEmpty title={props.empty.plugins} description={props.labels.pluginsDescription} />}
|
||||
>
|
||||
<ToolsList description={props.labels.pluginsDescription}>
|
||||
<For each={props.plugins}>
|
||||
{(plugin) => (
|
||||
<div class="flex h-8 items-center gap-2 px-2">
|
||||
<StatusDot status="connected" />
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base">
|
||||
{plugin}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ToolsList>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsList(props: { description: string; children: JSXElement }) {
|
||||
return (
|
||||
<div class="mt-2 flex flex-col">
|
||||
<div class="flex h-8 items-center px-2 text-[11px] font-[440] leading-4 tracking-[0.05px] text-v2-text-text-faint">
|
||||
{props.description}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolsEmpty(props: { title: JSXElement; description: string }) {
|
||||
return (
|
||||
<div class="mt-4 flex flex-col gap-2 text-center text-[13px] leading-4 tracking-[-0.04px]">
|
||||
<div class="h-4 font-[530] text-v2-text-text-muted">{props.title}</div>
|
||||
<div class="h-4 font-[440] text-v2-text-text-faint">{props.description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot(props: { status?: ToolsMenuStatus }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 shrink-0 rounded-full": true,
|
||||
"bg-v2-state-fg-success": props.status === "connected",
|
||||
"bg-v2-state-fg-danger": props.status === "failed" || props.status === "error",
|
||||
"bg-v2-state-fg-warning": props.status === "needs_auth" || props.status === "needs_client_registration",
|
||||
"bg-v2-border-border-base": props.status === "disabled",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
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", () => {
|
||||
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 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 EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||
|
||||
@@ -87,6 +87,13 @@ export interface CommandOption {
|
||||
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) {
|
||||
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 = () => {
|
||||
run("file.open", "palette")
|
||||
run(PALETTE_ID, "palette")
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
@@ -72,8 +73,13 @@ describe("Home V2 session index", () => {
|
||||
})
|
||||
|
||||
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 }),
|
||||
])
|
||||
@@ -88,6 +94,10 @@ describe("Home V2 session index", () => {
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "active-null",
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
|
||||
// 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 || item.time.archived !== undefined) return []
|
||||
if (item.parentID || typeof item.time.archived === "number") return []
|
||||
return [toLegacySummary(item)]
|
||||
})
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb
|
||||
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 || info.time.archived !== undefined) {
|
||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -62,9 +64,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
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 [saved, setSaved, , savedReady] = persisted(
|
||||
@@ -107,9 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const pickAgent = (name: string | undefined) => {
|
||||
const items = list()
|
||||
if (items.length === 0) return
|
||||
return items.find((item) => item.name === name) ?? items[0]
|
||||
return resolveAgent(list(), name)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -180,8 +182,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
visible: agentsVisible,
|
||||
current() {
|
||||
return pickAgent(scope()?.agent ?? store.current)
|
||||
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
|
||||
@@ -176,7 +176,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
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 {
|
||||
ready: () => selected().ready(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
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 }) =>
|
||||
({
|
||||
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
|
||||
permission: { sessionID: 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))
|
||||
.find((item): item is boolean => item !== undefined)
|
||||
return value ?? false
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { useSettings } from "./settings"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
sessionAutoAccept,
|
||||
} from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
@@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
|
||||
|
||||
const activeDraft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
|
||||
return activeDraft()?.server ?? server.key
|
||||
})
|
||||
|
||||
const ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Permission server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
|
||||
return existing.state
|
||||
}
|
||||
if (existing) {
|
||||
existing.dispose()
|
||||
states.delete(ctx.sdk.scope)
|
||||
}
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
key,
|
||||
dispose,
|
||||
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
)
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
const keys = new Set(list.map(ServerConnection.key))
|
||||
states.forEach((value, scope) => {
|
||||
if (keys.has(value.key)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
|
||||
if (replacement) ensure(ServerConnection.key(replacement))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
let lastSelected: PermissionState | undefined
|
||||
const selected = () => {
|
||||
const key = activeServer()
|
||||
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
|
||||
lastSelected = ensure(key)
|
||||
}
|
||||
if (lastSelected) return lastSelected
|
||||
return ensure(server.key)
|
||||
}
|
||||
const activeDirectory = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
if (directory) return directory
|
||||
const draft = activeDraft()
|
||||
if (draft) return draft.directory
|
||||
if (!params.id) return
|
||||
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
||||
return selected().sync.session.lineage.peek(params.id)?.session.directory
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const directory = activeDirectory()
|
||||
if (!directory) return
|
||||
selected().enableConfiguredDirectory(directory)
|
||||
})
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
const directory = activeDirectory()
|
||||
if (!directory) return false
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
return selected().permissionsEnabled(directory)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
serverSDK()
|
||||
.client.permission.respond(input)
|
||||
.catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
const perm = event.properties
|
||||
if (!shouldAutoRespond(perm, e.name)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return shouldAutoRespond(permission, directory)
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
|
||||
currentServerState: () => selected().api,
|
||||
respond(input: Parameters<PermissionRespondFn>[0]) {
|
||||
selected().respond(input)
|
||||
},
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return selected().autoResponds(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return selected().isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
return selected().isAutoAcceptingDirectory(directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
isAutoAcceptingDirectory,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
selected().toggleAutoAccept(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
selected().toggleAutoAcceptDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
selected().enableAutoAccept(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
disable(sessionID, directory)
|
||||
selected().disableAutoAccept(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
return selected().isPermissionAllowAll(directory)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type PermissionState = ReturnType<typeof createServerPermissionState>
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||
|
||||
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
function enableConfiguredDirectory(directory: string) {
|
||||
if (meta.disposed || !ready()) return
|
||||
const [childStore] = input.sync.child(directory)
|
||||
if (childStore.config.permission !== "allow") return
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] !== undefined) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
const meta = { disposed: false }
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (request) => {
|
||||
if (meta.disposed) return
|
||||
input.sdk.client.permission.respond(request).catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function sessions(directory?: string) {
|
||||
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||
if (!directory) return info
|
||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
const pending = input.sync.session.data.permission[permission.sessionID]
|
||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
if (meta.disposed || !lineage) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
}
|
||||
|
||||
async function respondPending(
|
||||
permission: PermissionRequest,
|
||||
directory?: string,
|
||||
current: () => boolean = () => true,
|
||||
) {
|
||||
if (!current() || !isPending(permission)) return
|
||||
if (!(await shouldAutoRespondResolved(permission, directory))) return
|
||||
if (meta.disposed || !current() || !isPending(permission)) return
|
||||
respondOnce(permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
return
|
||||
}
|
||||
void ready.promise?.then(() => {
|
||||
if (meta.disposed) return
|
||||
handlePermission(event)
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (meta.disposed) return
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (meta.disposed) return
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(
|
||||
perm,
|
||||
directory,
|
||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const api = {
|
||||
ready: () => !meta.disposed && ready(),
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAcceptingDirectory(directory)
|
||||
},
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
isPermissionAllowAll(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return childStore.config.permission === "allow"
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...api,
|
||||
api,
|
||||
sync: input.sync,
|
||||
enableConfiguredDirectory,
|
||||
permissionsEnabled(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return hasPermissionPromptRules(childStore.config.permission)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,9 @@ type PlatformBase = {
|
||||
/** Export collected diagnostic logs (desktop only) */
|
||||
exportDebugLogs?(): Promise<string>
|
||||
|
||||
/** Force focus styles on interactive elements through desktop devtools (desktop only) */
|
||||
setForceFocus?(enabled: boolean): Promise<void>
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
type PromptStore = {
|
||||
export type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
@@ -189,6 +189,7 @@ function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
@@ -36,6 +36,7 @@ export type {
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
PromptStore,
|
||||
PromptScope,
|
||||
PromptSession,
|
||||
TextPart,
|
||||
@@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
createResource(
|
||||
async () => {
|
||||
const value = cb()
|
||||
await session().ready.promise
|
||||
return value
|
||||
},
|
||||
cb,
|
||||
{ initialValue: cb() },
|
||||
)[0]
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
model: {
|
||||
current: () => session().model.current(),
|
||||
current: withSuspense(() => session().model.current()),
|
||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||
},
|
||||
context: {
|
||||
items: () => session().context.items(),
|
||||
items: withSuspense(() => session().context.items()),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -143,9 +142,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore({
|
||||
status: "connecting" as "connecting" | "connected" | "reconnecting",
|
||||
})
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -191,7 +187,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})
|
||||
},
|
||||
})
|
||||
setConnection("status", "connected")
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
@@ -223,7 +218,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
setConnection("status", "reconnecting")
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(() => {
|
||||
@@ -275,7 +269,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
connection,
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
return createSdkForServer({
|
||||
server: server.http,
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -194,12 +194,55 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const assistant = assistantMessage("message-2", "message-missing")
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
|
||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const parent = userMessage("message-1")
|
||||
const part = textPart(parent.id)
|
||||
const assistant = assistantMessage("message-2", parent.id)
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([
|
||||
{ info: parent, parts: [part] },
|
||||
{ info: assistant, parts: [] },
|
||||
]),
|
||||
response([{ info: assistant, parts: [] }], "older"),
|
||||
],
|
||||
[missing.promise],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 2
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
@@ -110,6 +110,7 @@ function reconcileFetched<T extends { id: string }>(
|
||||
options: {
|
||||
touched?: ReadonlySet<string>
|
||||
retained?: ReadonlySet<string>
|
||||
removed?: ReadonlySet<string>
|
||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||
} = {},
|
||||
) {
|
||||
@@ -132,6 +133,7 @@ function reconcileFetched<T extends { id: string }>(
|
||||
if (item) result.set(id, item)
|
||||
if (!item) result.delete(id)
|
||||
}
|
||||
for (const id of options.removed ?? emptyIDs) result.delete(id)
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
@@ -577,6 +579,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
removed: load?.removedMessages,
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
@@ -645,7 +648,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
if (generations.get(sessionID) !== active) break
|
||||
const parent = await fetchMessage(sessionID, parentID, () =>
|
||||
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
||||
)
|
||||
).catch((error) => {
|
||||
const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined
|
||||
if (cause && "status" in cause && cause.status === 404) {
|
||||
load.removedMessages.add(parentID)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
})
|
||||
if (!parent) continue
|
||||
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
||||
parents.push(parent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
isAppUpgrade,
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
shouldDisplayTabsToast,
|
||||
shouldEnableNewLayout,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
test("blank profiles default to the new layout", () => {
|
||||
expect(newLayoutDesignsDefault).toBe(true)
|
||||
})
|
||||
|
||||
test("hides the transition until a sunset is scheduled", () => {
|
||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||
})
|
||||
|
||||
test("existing profiles can switch before sunset", () => {
|
||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||
})
|
||||
|
||||
test("preserves explicit and default layout preferences", () => {
|
||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||
})
|
||||
|
||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||
})
|
||||
|
||||
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
||||
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
||||
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
||||
})
|
||||
|
||||
test("enables the new layout when no previous version was recorded", () => {
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
||||
})
|
||||
|
||||
test("detects upgrades only when a previous version is older", () => {
|
||||
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
||||
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
||||
})
|
||||
|
||||
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
||||
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not enable the new layout without a qualifying upgrade", () => {
|
||||
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -34,6 +35,9 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
shouldDisplayTabsToast?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -52,7 +56,70 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
export const newLayoutDesignsDefault = true
|
||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
||||
|
||||
function compareVersions(a: string, b: string) {
|
||||
const parse = (version: string) => {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
||||
if (!match) return
|
||||
return match.slice(1).map(Number)
|
||||
}
|
||||
const left = parse(a)
|
||||
const right = parse(b)
|
||||
if (!left || !right) return
|
||||
const index = left.findIndex((part, index) => part !== right[index])
|
||||
return index === -1 ? 0 : left[index]! - right[index]!
|
||||
}
|
||||
|
||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
||||
if (!previous || !current) return false
|
||||
const comparison = compareVersions(current, previous)
|
||||
return comparison !== undefined && comparison > 0
|
||||
}
|
||||
|
||||
export function shouldDisplayTabsToast(
|
||||
previous: string | undefined,
|
||||
current: string | undefined,
|
||||
existingInstall: boolean,
|
||||
) {
|
||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
||||
}
|
||||
|
||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||
if (!current) return false
|
||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
||||
if (!isAppUpgrade(previous, current)) return false
|
||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
||||
return (
|
||||
previousComparison !== undefined &&
|
||||
currentComparison !== undefined &&
|
||||
previousComparison <= 0 &&
|
||||
currentComparison > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
available: scheduled && eligible && !retired,
|
||||
notice: scheduled && eligible && retired && !dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
export const maximumSunsetTimeout = 2_147_483_647
|
||||
|
||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||
}
|
||||
|
||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||
if (retired) return true
|
||||
return preference ?? fallback
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -152,7 +219,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const [launch, setLaunch, , launchReady] = persisted(
|
||||
"app-version.v1",
|
||||
createStore<{ version?: string }>({ version: undefined }),
|
||||
)
|
||||
const [launchState, setLaunchState] = createStore({
|
||||
classified: false,
|
||||
migrationApplied: false,
|
||||
previous: undefined as string | undefined,
|
||||
})
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -160,9 +237,87 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
)
|
||||
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
||||
const sunset = oldInterfaceSunset
|
||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutUpgrade = createMemo(() =>
|
||||
launchState.classified && !launchState.migrationApplied
|
||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
||||
: false,
|
||||
)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (layoutUpgrade()) return true
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
legacyNewLayoutDesignsDefault,
|
||||
)
|
||||
}
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||
)
|
||||
})
|
||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||
|
||||
if (sunset && !oldInterfaceRetired()) {
|
||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const checkSunset = () => {
|
||||
if (Date.now() >= sunset.getTime()) {
|
||||
setOldInterfaceRetired(true)
|
||||
return
|
||||
}
|
||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||
}
|
||||
checkSunset()
|
||||
onCleanup(() => {
|
||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!launchReady() || launchState.classified) return
|
||||
setLaunchState({
|
||||
classified: true,
|
||||
previous: launch.version,
|
||||
})
|
||||
if (!platform.version || launch.version === platform.version) return
|
||||
setLaunch("version", platform.version)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
}
|
||||
setLaunchState("migrationApplied", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified) return
|
||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
||||
setStore(
|
||||
"general",
|
||||
"shouldDisplayTabsToast",
|
||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
@@ -250,7 +405,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
const next = oldInterfaceRetired() ? true : value
|
||||
if (newLayoutDesigns() === next) return
|
||||
setStore("general", "newLayoutDesigns", next)
|
||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
const current = store.general?.layoutTransitionEligible
|
||||
if (typeof current === "boolean") return
|
||||
setStore("general", "layoutTransitionEligible", eligible)
|
||||
},
|
||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
||||
dismissTabsToast() {
|
||||
setStore("general", "shouldDisplayTabsToast", false)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
|
||||
@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||
return tab
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||
const draftID = uuid()
|
||||
const tab = { type: "draft" as const, draftID, ...draft }
|
||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||
void startTransition(() => {
|
||||
await startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push(tab)
|
||||
@@ -220,6 +220,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
navigate(draftHref(draftID))
|
||||
})
|
||||
return tab
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
void startTransition(() => {
|
||||
|
||||
@@ -163,6 +163,43 @@ function createWorkspaceTerminalSession(
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
const [ui, setUi] = createStore({
|
||||
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
|
||||
})
|
||||
const focus = { request: 0 }
|
||||
|
||||
const requestFocus = (id?: string, pending = false) => {
|
||||
focus.request += 1
|
||||
setUi("focus", { request: focus.request, id, pending })
|
||||
return focus.request
|
||||
}
|
||||
|
||||
const focusRequested = (id?: string) => {
|
||||
if (!id) return false
|
||||
if (!ui.focus || ui.focus.pending) return false
|
||||
return !ui.focus.id || ui.focus.id === id
|
||||
}
|
||||
|
||||
const consumeFocus = (id: string) => {
|
||||
if (!focusRequested(id)) return
|
||||
setUi("focus", undefined)
|
||||
}
|
||||
|
||||
const cancelFocus = (request?: number) => {
|
||||
if (request !== undefined && ui.focus?.request !== request) return
|
||||
setUi("focus", undefined)
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
const cancelOnOutsideFocus = (event: FocusEvent) => {
|
||||
if (!ui.focus) return
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.target.closest("#terminal-panel")) return
|
||||
cancelFocus()
|
||||
}
|
||||
document.addEventListener("focusin", cancelOnOutsideFocus)
|
||||
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
|
||||
}
|
||||
|
||||
const pickNextTerminalNumber = () => {
|
||||
const existingTitleNumbers = new Set(
|
||||
@@ -267,23 +304,33 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", [])
|
||||
})
|
||||
},
|
||||
new() {
|
||||
new(options?: { focus?: boolean }) {
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: defaultTitle(nextNumber) })
|
||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
if (!id) {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
return
|
||||
}
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
titleNumber: nextNumber,
|
||||
}
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
batch(() => {
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
|
||||
setUi("focus", { request: focusRequest, id, pending: false })
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
console.error("Failed to create terminal", error)
|
||||
})
|
||||
},
|
||||
@@ -324,6 +371,18 @@ function createWorkspaceTerminalSession(
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
},
|
||||
requestFocus(id?: string) {
|
||||
requestFocus(id)
|
||||
},
|
||||
focusRequested(id?: string) {
|
||||
return focusRequested(id)
|
||||
},
|
||||
consumeFocus(id: string) {
|
||||
consumeFocus(id)
|
||||
},
|
||||
cancelFocus() {
|
||||
cancelFocus()
|
||||
},
|
||||
next() {
|
||||
const index = store.all.findIndex((x) => x.id === store.active)
|
||||
if (index === -1) return
|
||||
@@ -442,13 +501,17 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
new: (options?: { focus?: boolean }) => workspace().new(options),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
bind: () => workspace(),
|
||||
open: (id: string) => workspace().open(id),
|
||||
requestFocus: (id?: string) => workspace().requestFocus(id),
|
||||
focusRequested: (id?: string) => workspace().focusRequested(id),
|
||||
consumeFocus: (id: string) => workspace().consumeFocus(id),
|
||||
cancelFocus: () => workspace().cancelFocus(),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
next: () => workspace().next(),
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
||||
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
||||
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
|
||||
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
|
||||
"palette.empty": "لا توجد نتائج",
|
||||
"palette.group.commands": "الأوامر",
|
||||
"palette.group.files": "الملفات",
|
||||
@@ -612,6 +613,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "محلي",
|
||||
"session.new.workspace.local": "المستودع المحلي",
|
||||
"session.new.workspace.existing": "مساحة عمل…",
|
||||
"session.new.git.none": "لا يوجد Git",
|
||||
"session.new.lastModified": "آخر تعديل",
|
||||
"session.header.search.placeholder": "بحث {{project}}",
|
||||
"session.header.searchFiles": "بحث عن الملفات",
|
||||
@@ -621,14 +623,6 @@ export const dict = {
|
||||
"session.header.open.menu": "خيارات الفتح",
|
||||
"session.header.open.copyPath": "نسخ المسار",
|
||||
"status.popover.trigger": "الحالة",
|
||||
"status.popover.tools.trigger": "الأدوات",
|
||||
"status.popover.tools.mcp.description": "أضف خوادم MCP في opencode.json",
|
||||
"status.popover.tools.lsp.description": "يتم اكتشاف خوادم LSP تلقائيًا عند فتح الملفات",
|
||||
"status.popover.tools.plugins.description": "أضف الإضافات في opencode.json",
|
||||
"status.popover.tools.mcp.empty": "لم يتم إعداد أي خوادم MCP بعد",
|
||||
"status.popover.tools.lsp.empty": "لا توجد خوادم لغة نشطة بعد",
|
||||
"status.popover.tools.plugins.empty": "لم يتم إعداد أي إضافات بعد",
|
||||
"status.popover.tools.reauthenticate": "إعادة المصادقة",
|
||||
"status.popover.ariaLabel": "إعدادات الخوادم",
|
||||
"status.popover.tab.servers": "الخوادم",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -730,8 +724,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
|
||||
"settings.general.row.showStatus.title": "حالة الخادم",
|
||||
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
|
||||
"settings.general.row.showTools.title": "قائمة الأدوات",
|
||||
"settings.general.row.showTools.description": "إظهار وصول سريع إلى MCP وLSP والإضافات في شريط العنوان",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
|
||||
@@ -745,9 +737,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
|
||||
"settings.general.row.newInterface.title": "التخطيط الجديد",
|
||||
"settings.general.row.newInterface.badge": "جديد",
|
||||
"settings.general.row.newInterface.description":
|
||||
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
||||
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
||||
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
|
||||
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
||||
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
@@ -939,8 +935,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
||||
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
|
||||
"app.server.status.reconnecting": "جارٍ إعادة الاتصال بـ {{server}}...",
|
||||
"app.server.status.disconnected": "تعذر الاتصال بـ {{server}}",
|
||||
"app.server.otherServers": "خوادم أخرى",
|
||||
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
||||
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "Parar de compartilhar sessão",
|
||||
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
||||
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
|
||||
"palette.search.placeholder.home": "Buscar comandos e sessões",
|
||||
"palette.empty": "Nenhum resultado encontrado",
|
||||
"palette.group.commands": "Comandos",
|
||||
"palette.group.files": "Arquivos",
|
||||
@@ -618,6 +619,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Repositório local",
|
||||
"session.new.workspace.existing": "Espaço de trabalho…",
|
||||
"session.new.git.none": "Sem Git",
|
||||
"session.new.lastModified": "Última modificação",
|
||||
"session.header.search.placeholder": "Buscar {{project}}",
|
||||
"session.header.searchFiles": "Buscar arquivos",
|
||||
@@ -627,14 +629,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Opções de abertura",
|
||||
"session.header.open.copyPath": "Copiar caminho",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Ferramentas",
|
||||
"status.popover.tools.mcp.description": "Adicione servidores MCP no opencode.json",
|
||||
"status.popover.tools.lsp.description": "Os LSPs são detectados automaticamente ao abrir arquivos",
|
||||
"status.popover.tools.plugins.description": "Adicione plugins no opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Nenhum MCP configurado ainda",
|
||||
"status.popover.tools.lsp.empty": "Nenhum servidor de linguagem ativo ainda",
|
||||
"status.popover.tools.plugins.empty": "Nenhum plugin configurado ainda",
|
||||
"status.popover.tools.reauthenticate": "Autenticar novamente",
|
||||
"status.popover.ariaLabel": "Configurações de servidores",
|
||||
"status.popover.tab.servers": "Servidores",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -741,8 +735,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop",
|
||||
"settings.general.row.showStatus.title": "Status do servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título",
|
||||
"settings.general.row.showTools.title": "Menu de ferramentas",
|
||||
"settings.general.row.showTools.description": "Mostrar acesso rápido a MCPs, LSPs e plugins na barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
|
||||
@@ -756,9 +748,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.newLayoutDesigns.title": "Novo layout e design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados",
|
||||
"settings.general.row.newInterface.title": "Novo layout",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
|
||||
@@ -953,8 +949,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
||||
"app.server.retrying": "Tentando novamente automaticamente...",
|
||||
"app.server.status.reconnecting": "Reconectando a {{server}}...",
|
||||
"app.server.status.disconnected": "Não foi possível conectar a {{server}}",
|
||||
"app.server.otherServers": "Outros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "nome de usuário",
|
||||
"dialog.server.add.passwordPlaceholder": "senha",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
|
||||
|
||||
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
|
||||
"palette.search.placeholder.home": "Pretraži komande i sesije",
|
||||
"palette.empty": "Nema rezultata",
|
||||
"palette.group.commands": "Komande",
|
||||
"palette.group.files": "Datoteke",
|
||||
@@ -674,6 +675,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Lokalno",
|
||||
"session.new.workspace.local": "Lokalni repozitorij",
|
||||
"session.new.workspace.existing": "Radni prostor…",
|
||||
"session.new.git.none": "Nema Gita",
|
||||
"session.new.lastModified": "Posljednja izmjena",
|
||||
|
||||
"session.header.search.placeholder": "Pretraži {{project}}",
|
||||
@@ -685,14 +687,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopiraj putanju",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Alati",
|
||||
"status.popover.tools.mcp.description": "Dodajte MCP servere u opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP serveri se automatski otkrivaju dok otvarate datoteke",
|
||||
"status.popover.tools.plugins.description": "Dodajte dodatke u opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Još nema konfigurisanih MCP servera",
|
||||
"status.popover.tools.lsp.empty": "Još nema aktivnih jezičkih servera",
|
||||
"status.popover.tools.plugins.empty": "Još nema konfigurisanih dodataka",
|
||||
"status.popover.tools.reauthenticate": "Ponovo se autentificiraj",
|
||||
"status.popover.ariaLabel": "Konfiguracije servera",
|
||||
"status.popover.tab.servers": "Serveri",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -805,8 +799,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije",
|
||||
"settings.general.row.showStatus.title": "Status servera",
|
||||
"settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci",
|
||||
"settings.general.row.showTools.title": "Meni alata",
|
||||
"settings.general.row.showTools.description": "Prikaži brzi pristup MCP, LSP i dodacima u naslovnoj traci",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Donja navigacija",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima",
|
||||
@@ -821,9 +813,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije",
|
||||
"settings.general.row.newInterface.title": "Novi raspored",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
|
||||
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
||||
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
||||
@@ -1029,8 +1025,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
||||
"app.server.retrying": "Automatski ponovni pokušaj...",
|
||||
"app.server.status.reconnecting": "Ponovno povezivanje sa {{server}}...",
|
||||
"app.server.status.disconnected": "Nije moguće povezati se sa {{server}}",
|
||||
"app.server.otherServers": "Drugi serveri",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Stop med at dele denne session",
|
||||
|
||||
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
|
||||
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
|
||||
"palette.empty": "Ingen resultater fundet",
|
||||
"palette.group.commands": "Kommandoer",
|
||||
"palette.group.files": "Filer",
|
||||
@@ -669,6 +670,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Lokal",
|
||||
"session.new.workspace.local": "Lokalt repository",
|
||||
"session.new.workspace.existing": "Arbejdsområde…",
|
||||
"session.new.git.none": "Ingen Git",
|
||||
"session.new.lastModified": "Sidst ændret",
|
||||
|
||||
"session.header.search.placeholder": "Søg {{project}}",
|
||||
@@ -680,14 +682,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Kopier sti",
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Værktøjer",
|
||||
"status.popover.tools.mcp.description": "Tilføj MCP-servere i opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSP'er registreres automatisk, når du åbner filer",
|
||||
"status.popover.tools.plugins.description": "Tilføj plugins i opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Ingen MCP'er konfigureret endnu",
|
||||
"status.popover.tools.lsp.empty": "Ingen aktive sprogservere endnu",
|
||||
"status.popover.tools.plugins.empty": "Ingen plugins konfigureret endnu",
|
||||
"status.popover.tools.reauthenticate": "Godkend igen",
|
||||
"status.popover.ariaLabel": "Serverkonfigurationer",
|
||||
"status.popover.tab.servers": "Servere",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -799,8 +793,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen",
|
||||
"settings.general.row.showTools.title": "Værktøjsmenu",
|
||||
"settings.general.row.showTools.description": "Vis hurtig adgang til MCP'er, LSP'er og plugins i titellinjen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
|
||||
@@ -814,9 +806,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen",
|
||||
"settings.general.row.newInterface.title": "Nyt layout",
|
||||
"settings.general.row.newInterface.badge": "Ny",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
||||
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
||||
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||
@@ -1021,8 +1017,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver igen automatisk...",
|
||||
"app.server.status.reconnecting": "Opretter forbindelse til {{server}} igen...",
|
||||
"app.server.status.disconnected": "Kunne ikke oprette forbindelse til {{server}}",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brugernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "adgangskode",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const dict = {
|
||||
"command.session.unshare": "Teilen der Sitzung aufheben",
|
||||
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
|
||||
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
|
||||
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
|
||||
"palette.empty": "Keine Ergebnisse gefunden",
|
||||
"palette.group.commands": "Befehle",
|
||||
"palette.group.files": "Dateien",
|
||||
@@ -627,6 +628,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Lokal",
|
||||
"session.new.workspace.local": "Lokales Repository",
|
||||
"session.new.workspace.existing": "Arbeitsbereich…",
|
||||
"session.new.git.none": "Kein Git",
|
||||
"session.new.lastModified": "Zuletzt geändert",
|
||||
"session.header.search.placeholder": "{{project}} durchsuchen",
|
||||
"session.header.searchFiles": "Dateien suchen",
|
||||
@@ -636,14 +638,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Öffnen-Optionen",
|
||||
"session.header.open.copyPath": "Pfad kopieren",
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.tools.trigger": "Werkzeuge",
|
||||
"status.popover.tools.mcp.description": "MCP-Server in opencode.json hinzufügen",
|
||||
"status.popover.tools.lsp.description": "LSPs werden beim Öffnen von Dateien automatisch erkannt",
|
||||
"status.popover.tools.plugins.description": "Plugins in opencode.json hinzufügen",
|
||||
"status.popover.tools.mcp.empty": "Noch keine MCPs konfiguriert",
|
||||
"status.popover.tools.lsp.empty": "Noch keine aktiven Sprachserver",
|
||||
"status.popover.tools.plugins.empty": "Noch keine Plugins konfiguriert",
|
||||
"status.popover.tools.reauthenticate": "Erneut authentifizieren",
|
||||
"status.popover.ariaLabel": "Serverkonfigurationen",
|
||||
"status.popover.tab.servers": "Server",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -753,8 +747,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen",
|
||||
"settings.general.row.showStatus.title": "Serverstatus",
|
||||
"settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen",
|
||||
"settings.general.row.showTools.title": "Werkzeugmenü",
|
||||
"settings.general.row.showTools.description": "Schnellzugriff auf MCPs, LSPs und Plugins in der Titelleiste anzeigen",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation unten",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren",
|
||||
@@ -769,9 +761,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren",
|
||||
"settings.general.row.newInterface.title": "Neues Layout",
|
||||
"settings.general.row.newInterface.badge": "Neu",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
|
||||
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
|
||||
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
@@ -965,8 +961,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
|
||||
"app.server.retrying": "Automatische erneute Verbindung...",
|
||||
"app.server.status.reconnecting": "Verbindung zu {{server}} wird wiederhergestellt...",
|
||||
"app.server.status.disconnected": "Verbindung zu {{server}} konnte nicht hergestellt werden",
|
||||
"app.server.otherServers": "Andere Server",
|
||||
"dialog.server.add.usernamePlaceholder": "Benutzername",
|
||||
"dialog.server.add.passwordPlaceholder": "Passwort",
|
||||
|
||||
+16
-19
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Stop sharing this session",
|
||||
|
||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||
"palette.search.placeholder.home": "Search commands and sessions",
|
||||
"palette.empty": "No results found",
|
||||
"palette.group.commands": "Commands",
|
||||
"palette.group.files": "Files",
|
||||
@@ -101,6 +102,7 @@ export const dict = {
|
||||
"dialog.provider.empty": "No providers found",
|
||||
"dialog.provider.group.popular": "Popular",
|
||||
"dialog.provider.group.other": "Other",
|
||||
"dialog.provider.custom.label": "Custom OpenAI-compatible provider",
|
||||
"dialog.provider.tag.recommended": "Recommended",
|
||||
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
||||
"dialog.provider.opencode.tagline": "Reliable optimized models",
|
||||
@@ -121,6 +123,7 @@ export const dict = {
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
|
||||
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
|
||||
|
||||
"dialog.provider.viewAll": "Show more providers",
|
||||
|
||||
@@ -332,8 +335,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Could not reach {{server}}",
|
||||
"app.server.retrying": "Retrying automatically...",
|
||||
"app.server.status.reconnecting": "Reconnecting to {{server}}...",
|
||||
"app.server.status.disconnected": "Couldn't connect to {{server}}",
|
||||
"app.server.otherServers": "Other servers",
|
||||
|
||||
"dialog.server.title": "Servers",
|
||||
@@ -699,6 +700,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
"session.new.workspace.existing": "Workspace…",
|
||||
"session.new.git.none": "No Git",
|
||||
"session.new.lastModified": "Last modified",
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
@@ -730,14 +732,6 @@ export const dict = {
|
||||
|
||||
"status.popover.trigger": "Status",
|
||||
"status.popover.ariaLabel": "Server configurations",
|
||||
"status.popover.tools.trigger": "Tools",
|
||||
"status.popover.tools.mcp.description": "Add MCP servers in opencode.json",
|
||||
"status.popover.tools.lsp.description": "LSPs are auto-detected as you open files",
|
||||
"status.popover.tools.plugins.description": "Add plugins in opencode.json",
|
||||
"status.popover.tools.mcp.empty": "No MCPs configured yet",
|
||||
"status.popover.tools.lsp.empty": "No active language servers yet",
|
||||
"status.popover.tools.plugins.empty": "No plugins configured yet",
|
||||
"status.popover.tools.reauthenticate": "Reauthenticate",
|
||||
"status.popover.tab.servers": "Servers",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
@@ -865,9 +859,8 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminal Shell",
|
||||
"settings.general.row.shell.description":
|
||||
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
|
||||
"settings.general.row.shell.title": "Terminal shell",
|
||||
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
|
||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
@@ -896,13 +889,12 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showTools.title": "Tools menu",
|
||||
"settings.general.row.showTools.description": "Show quick access to MCPs, LSPs, and plugins in the title bar",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -911,8 +903,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.newInterface.title": "New layout",
|
||||
"settings.general.row.newInterface.badge": "New",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export const dict = {
|
||||
"command.session.unshare.description": "Dejar de compartir esta sesión",
|
||||
|
||||
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
|
||||
"palette.search.placeholder.home": "Buscar comandos y sesiones",
|
||||
"palette.empty": "No se encontraron resultados",
|
||||
"palette.group.commands": "Comandos",
|
||||
"palette.group.files": "Archivos",
|
||||
@@ -675,6 +676,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Repositorio local",
|
||||
"session.new.workspace.existing": "Espacio de trabajo…",
|
||||
"session.new.git.none": "Sin Git",
|
||||
"session.new.lastModified": "Última modificación",
|
||||
|
||||
"session.header.search.placeholder": "Buscar {{project}}",
|
||||
@@ -686,14 +688,6 @@ export const dict = {
|
||||
"session.header.open.copyPath": "Copiar ruta",
|
||||
|
||||
"status.popover.trigger": "Estado",
|
||||
"status.popover.tools.trigger": "Herramientas",
|
||||
"status.popover.tools.mcp.description": "Añade servidores MCP en opencode.json",
|
||||
"status.popover.tools.lsp.description": "Los LSP se detectan automáticamente al abrir archivos",
|
||||
"status.popover.tools.plugins.description": "Añade plugins en opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Aún no hay MCP configurados",
|
||||
"status.popover.tools.lsp.empty": "Aún no hay servidores de lenguaje activos",
|
||||
"status.popover.tools.plugins.empty": "Aún no hay plugins configurados",
|
||||
"status.popover.tools.reauthenticate": "Volver a autenticar",
|
||||
"status.popover.ariaLabel": "Configuraciones del servidor",
|
||||
"status.popover.tab.servers": "Servidores",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -810,8 +804,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título",
|
||||
"settings.general.row.showStatus.title": "Estado del servidor",
|
||||
"settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título",
|
||||
"settings.general.row.showTools.title": "Menú de herramientas",
|
||||
"settings.general.row.showTools.description": "Mostrar acceso rápido a MCP, LSP y plugins en la barra de título",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navegación inferior",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
|
||||
@@ -826,9 +818,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones",
|
||||
"settings.general.row.newInterface.title": "Nuevo diseño",
|
||||
"settings.general.row.newInterface.badge": "Nuevo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
|
||||
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
|
||||
@@ -1037,8 +1033,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "No se pudo conectar con {{server}}",
|
||||
"app.server.retrying": "Reintentando automáticamente...",
|
||||
"app.server.status.reconnecting": "Reconectando con {{server}}...",
|
||||
"app.server.status.disconnected": "No se pudo conectar con {{server}}",
|
||||
"app.server.otherServers": "Otros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "usuario",
|
||||
"dialog.server.add.passwordPlaceholder": "contraseña",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "Ne plus partager la session",
|
||||
"command.session.unshare.description": "Arrêter de partager cette session",
|
||||
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
|
||||
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
|
||||
"palette.empty": "Aucun résultat trouvé",
|
||||
"palette.group.commands": "Commandes",
|
||||
"palette.group.files": "Fichiers",
|
||||
@@ -623,6 +624,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Dépôt local",
|
||||
"session.new.workspace.existing": "Espace de travail…",
|
||||
"session.new.git.none": "Pas de Git",
|
||||
"session.new.lastModified": "Dernière modification",
|
||||
"session.header.search.placeholder": "Rechercher {{project}}",
|
||||
"session.header.searchFiles": "Rechercher des fichiers",
|
||||
@@ -632,14 +634,6 @@ export const dict = {
|
||||
"session.header.open.menu": "Options d'ouverture",
|
||||
"session.header.open.copyPath": "Copier le chemin",
|
||||
"status.popover.trigger": "Statut",
|
||||
"status.popover.tools.trigger": "Outils",
|
||||
"status.popover.tools.mcp.description": "Ajouter des serveurs MCP dans opencode.json",
|
||||
"status.popover.tools.lsp.description": "Les LSP sont détectés automatiquement à l’ouverture des fichiers",
|
||||
"status.popover.tools.plugins.description": "Ajouter des plugins dans opencode.json",
|
||||
"status.popover.tools.mcp.empty": "Aucun MCP configuré pour le moment",
|
||||
"status.popover.tools.lsp.empty": "Aucun serveur de langage actif pour le moment",
|
||||
"status.popover.tools.plugins.empty": "Aucun plugin configuré pour le moment",
|
||||
"status.popover.tools.reauthenticate": "S’authentifier à nouveau",
|
||||
"status.popover.ariaLabel": "Configurations des serveurs",
|
||||
"status.popover.tab.servers": "Serveurs",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -750,9 +744,6 @@ export const dict = {
|
||||
"Afficher le bouton du terminal dans la barre de titre de l'application desktop",
|
||||
"settings.general.row.showStatus.title": "État du serveur",
|
||||
"settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre",
|
||||
"settings.general.row.showTools.title": "Menu Outils",
|
||||
"settings.general.row.showTools.description":
|
||||
"Afficher un accès rapide aux MCP, LSP et plugins dans la barre de titre",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Navigation en bas",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Placer la barre de titre et les onglets de session en bas de l'écran sur mobile",
|
||||
@@ -767,9 +758,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session",
|
||||
"settings.general.row.newInterface.title": "Nouvelle mise en page",
|
||||
"settings.general.row.newInterface.badge": "Nouveau",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
|
||||
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
|
||||
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
|
||||
@@ -965,8 +960,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "Impossible de joindre {{server}}",
|
||||
"app.server.retrying": "Nouvelle tentative automatique...",
|
||||
"app.server.status.reconnecting": "Reconnexion à {{server}}...",
|
||||
"app.server.status.disconnected": "Impossible de se connecter à {{server}}",
|
||||
"app.server.otherServers": "Autres serveurs",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
|
||||
"dialog.server.add.passwordPlaceholder": "mot de passe",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "セッションの共有を停止",
|
||||
"command.session.unshare.description": "このセッションの共有を停止",
|
||||
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
|
||||
"palette.search.placeholder.home": "コマンドとセッションを検索",
|
||||
"palette.empty": "結果が見つかりません",
|
||||
"palette.group.commands": "コマンド",
|
||||
"palette.group.files": "ファイル",
|
||||
@@ -614,6 +615,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "ローカル",
|
||||
"session.new.workspace.local": "ローカルリポジトリ",
|
||||
"session.new.workspace.existing": "ワークスペース…",
|
||||
"session.new.git.none": "Git なし",
|
||||
"session.new.lastModified": "最終更新",
|
||||
"session.header.search.placeholder": "{{project}}を検索",
|
||||
"session.header.searchFiles": "ファイルを検索",
|
||||
@@ -623,14 +625,6 @@ export const dict = {
|
||||
"session.header.open.menu": "開くオプション",
|
||||
"session.header.open.copyPath": "パスをコピー",
|
||||
"status.popover.trigger": "ステータス",
|
||||
"status.popover.tools.trigger": "ツール",
|
||||
"status.popover.tools.mcp.description": "opencode.json に MCP サーバーを追加",
|
||||
"status.popover.tools.lsp.description": "ファイルを開くと LSP が自動検出されます",
|
||||
"status.popover.tools.plugins.description": "opencode.json にプラグインを追加",
|
||||
"status.popover.tools.mcp.empty": "MCP はまだ設定されていません",
|
||||
"status.popover.tools.lsp.empty": "アクティブな言語サーバーはまだありません",
|
||||
"status.popover.tools.plugins.empty": "プラグインはまだ設定されていません",
|
||||
"status.popover.tools.reauthenticate": "再認証",
|
||||
"status.popover.ariaLabel": "サーバー設定",
|
||||
"status.popover.tab.servers": "サーバー",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -736,8 +730,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "デスクトップのタイトルバーにターミナルボタンを表示します",
|
||||
"settings.general.row.showStatus.title": "サーバーステータス",
|
||||
"settings.general.row.showStatus.description": "タイトルバーにサーバーステータスボタンを表示します",
|
||||
"settings.general.row.showTools.title": "ツールメニュー",
|
||||
"settings.general.row.showTools.description": "タイトルバーに MCP、LSP、プラグインへのクイックアクセスを表示します",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "下部ナビゲーション",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"モバイルではタイトルバーとセッションタブを画面下部に配置します",
|
||||
@@ -751,9 +743,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします",
|
||||
"settings.general.row.newInterface.title": "新しいレイアウト",
|
||||
"settings.general.row.newInterface.badge": "新機能",
|
||||
"settings.general.row.newInterface.description":
|
||||
"新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。",
|
||||
"settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています",
|
||||
"settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "閉じる",
|
||||
"settings.general.row.pinchZoom.title": "ピンチでズーム",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
|
||||
@@ -946,8 +942,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}} に到達できませんでした",
|
||||
"app.server.retrying": "自動的に再試行中...",
|
||||
"app.server.status.reconnecting": "{{server}} に再接続しています...",
|
||||
"app.server.status.disconnected": "{{server}} に接続できませんでした",
|
||||
"app.server.otherServers": "その他のサーバー",
|
||||
"dialog.server.add.usernamePlaceholder": "ユーザー名",
|
||||
"dialog.server.add.passwordPlaceholder": "パスワード",
|
||||
|
||||
@@ -82,6 +82,7 @@ export const dict = {
|
||||
"command.session.unshare": "세션 공유 중지",
|
||||
"command.session.unshare.description": "이 세션 공유 중지",
|
||||
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
|
||||
"palette.search.placeholder.home": "명령어 및 세션 검색",
|
||||
"palette.empty": "결과 없음",
|
||||
"palette.group.commands": "명령어",
|
||||
"palette.group.files": "파일",
|
||||
@@ -503,14 +504,6 @@ export const dict = {
|
||||
"session.header.open.menu": "열기 옵션",
|
||||
"session.header.open.copyPath": "경로 복사",
|
||||
"status.popover.trigger": "상태",
|
||||
"status.popover.tools.trigger": "도구",
|
||||
"status.popover.tools.mcp.description": "opencode.json에 MCP 서버 추가",
|
||||
"status.popover.tools.lsp.description": "파일을 열면 LSP가 자동으로 감지됩니다",
|
||||
"status.popover.tools.plugins.description": "opencode.json에 플러그인 추가",
|
||||
"status.popover.tools.mcp.empty": "아직 구성된 MCP가 없습니다",
|
||||
"status.popover.tools.lsp.empty": "아직 활성 언어 서버가 없습니다",
|
||||
"status.popover.tools.plugins.empty": "아직 구성된 플러그인이 없습니다",
|
||||
"status.popover.tools.reauthenticate": "다시 인증",
|
||||
"status.popover.ariaLabel": "서버 구성",
|
||||
"status.popover.tab.servers": "서버",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
@@ -790,8 +783,6 @@ export const dict = {
|
||||
|
||||
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.retrying": "자동으로 재시도 중...",
|
||||
"app.server.status.reconnecting": "{{server}}에 다시 연결하는 중...",
|
||||
"app.server.status.disconnected": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.otherServers": "다른 서버",
|
||||
"dialog.server.add.usernamePlaceholder": "사용자 이름",
|
||||
"dialog.server.add.passwordPlaceholder": "비밀번호",
|
||||
@@ -985,6 +976,7 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "로컬",
|
||||
"session.new.workspace.local": "로컬 저장소",
|
||||
"session.new.workspace.existing": "작업 공간…",
|
||||
"session.new.git.none": "Git 없음",
|
||||
|
||||
"sidebar.empty.title": "열린 프로젝트 없음",
|
||||
"sidebar.empty.description": "프로젝트를 열어 시작하세요",
|
||||
@@ -1005,14 +997,17 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "데스크톱 제목 표시줄에 터미널 버튼 표시",
|
||||
"settings.general.row.showStatus.title": "서버 상태",
|
||||
"settings.general.row.showStatus.description": "제목 표시줄에 서버 상태 버튼 표시",
|
||||
"settings.general.row.showTools.title": "도구 메뉴",
|
||||
"settings.general.row.showTools.description": "제목 표시줄에 MCP, LSP 및 플러그인 빠른 접근 표시",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "하단 탐색",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
|
||||
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
|
||||
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
|
||||
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인",
|
||||
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화",
|
||||
"settings.general.row.newInterface.title": "새 레이아웃",
|
||||
"settings.general.row.newInterface.badge": "신규",
|
||||
"settings.general.row.newInterface.description":
|
||||
"새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.",
|
||||
"settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다",
|
||||
"settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "닫기",
|
||||
"settings.general.row.pinchZoom.title": "핀치 줌",
|
||||
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
|
||||
"settings.updates.action.downloading": "다운로드 중...",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user