mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
71 Commits
midstream-errs
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 78f88cb761 | |||
| c8ae3e4f03 | |||
| 39fcffa98a | |||
| 8cc9d0b248 | |||
| 436743572d | |||
| 65a978fcc1 | |||
| 1fe3831baf | |||
| 310924c5eb | |||
| 9d589b5059 | |||
| 470d7400f5 | |||
| 407853d8f3 | |||
| a45c4c149c | |||
| def7220bfc | |||
| b20b5a7f3b | |||
| e1f51ce53f | |||
| f6c5afe5a9 | |||
| 847771fe06 | |||
| e11d4a1fc9 | |||
| 1b99e64e67 | |||
| 5005898468 | |||
| 627e640c69 | |||
| b6b6f87969 | |||
| 9590b51a3f | |||
| f1476dc7d4 | |||
| b8bd88901a | |||
| f1adabcddc | |||
| a597364516 | |||
| c40aecbdbe | |||
| 24470e52a5 | |||
| d985fa4307 | |||
| 23bbc5cd14 | |||
| 6c1aabd5e0 | |||
| a16dd3d4ed | |||
| bbb1a19ed6 | |||
| ebf6fc07a1 | |||
| 04513d9692 | |||
| 146720e197 | |||
| b1f8cc04af | |||
| 082fe93e16 | |||
| 709c195905 | |||
| 3355b78d91 | |||
| b84c63d034 | |||
| 057b5a9dee | |||
| 61aefc0759 | |||
| f929f8f100 | |||
| 6b17dc6190 | |||
| e98b90fc51 | |||
| 1b0e4e4610 | |||
| 842f1dcfdb | |||
| 067dfa341f | |||
| f826f7fc9b | |||
| 27ecc46dc7 | |||
| 8c7c69c749 | |||
| 9b16d0b069 | |||
| ccc11dc92d | |||
| a47dabff22 | |||
| 1277ceb426 | |||
| 7b7335b7e9 | |||
| 7192fa8b7a | |||
| 4062b30409 | |||
| 7d6b8105f1 | |||
| a5a09c17fa | |||
| 10c6040b38 | |||
| f77f5a343e | |||
| 41bf4b140b | |||
| fd599277d6 | |||
| 2d27c49367 | |||
| 974271d6b1 | |||
| 985ee1e2ec | |||
| 83bee1e776 | |||
| 124714ca3a |
@@ -322,6 +322,7 @@ jobs:
|
|||||||
working-directory: packages/desktop
|
working-directory: packages/desktop
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -781,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
@@ -836,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -849,7 +849,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -883,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -902,7 +902,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -944,7 +944,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -971,7 +971,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1022,7 +1022,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1078,6 +1078,7 @@
|
|||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=",
|
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||||
"aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=",
|
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||||
"aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=",
|
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||||
"x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY="
|
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -158,6 +158,7 @@
|
|||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||||
|
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||||
|
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||||
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||||
|
|||||||
@@ -56,6 +56,32 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
|
|||||||
|
|
||||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||||
|
|
||||||
|
## Desktop profiler
|
||||||
|
|
||||||
|
The desktop profiler launches the existing production build directly, creates isolated desktop state, chooses an available CDP port, and writes reports under the OS temporary directory by default.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run profile:desktop --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a private partial snapshot from the default local database and run Home once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run profile:desktop --partial-snapshot-out /tmp/opencode-perf.db \
|
||||||
|
--window-end 2026-08-04T06:14:26.878Z \
|
||||||
|
--scenarios home,calibration --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat against the immutable partial snapshot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run profile:desktop --mode partial-snapshot --db /tmp/opencode-perf.db \
|
||||||
|
--window-end 2026-08-04T06:14:26.878Z \
|
||||||
|
--scenarios home,calibration --runs 3 --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Partial snapshots contain private application data and must not be committed or shared. The profiler copies each partial snapshot to a per-run working database and remaps selected project paths to temporary workspaces, leaving the source snapshot unchanged. `PROFILE_SUMMARY` is the compact comparison output; `PROFILE_REPORT` points to the complete JSON report with the database hash, invocation parameters, raw runs, and attribution data.
|
||||||
|
|
||||||
## Chrome traces
|
## Chrome traces
|
||||||
|
|
||||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import { mkdir, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
import type { Options, Target } from "./types"
|
||||||
|
|
||||||
|
export async function createPartialSnapshot(source: string, destination: string, options: Options, targets: Target[]) {
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
await rm(destination, { force: true })
|
||||||
|
const input = new Database(source, { readonly: true })
|
||||||
|
const schema = input
|
||||||
|
.query(
|
||||||
|
`SELECT type, name, sql FROM sqlite_schema
|
||||||
|
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`,
|
||||||
|
)
|
||||||
|
.all() as { type: string; name: string; sql: string }[]
|
||||||
|
input.close()
|
||||||
|
|
||||||
|
const output = new Database(destination, { create: true })
|
||||||
|
output.run("PRAGMA foreign_keys = OFF")
|
||||||
|
schema.filter((item) => item.type === "table").forEach((item) => output.run(item.sql))
|
||||||
|
output.run("ATTACH DATABASE ? AS source", source)
|
||||||
|
const selected = [...new Set(targets.map((target) => target.id))]
|
||||||
|
const placeholders = selected.map(() => "?").join(",")
|
||||||
|
|
||||||
|
for (const table of schema.filter((item) => item.type === "table").map((item) => item.name)) {
|
||||||
|
progress("copying partial snapshot table", { table })
|
||||||
|
if (table === "event") continue
|
||||||
|
if (table === "message") {
|
||||||
|
output.run(
|
||||||
|
`INSERT INTO main.message SELECT * FROM source.message
|
||||||
|
WHERE (time_created >= ? AND time_created < ? AND session_id IN (
|
||||||
|
SELECT id FROM source.session WHERE parent_id IS NULL
|
||||||
|
)) OR session_id IN (${placeholders})`,
|
||||||
|
options.windowStart,
|
||||||
|
options.windowEnd,
|
||||||
|
...selected,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (table === "part") {
|
||||||
|
output.run("INSERT INTO main.part SELECT * FROM source.part WHERE message_id IN (SELECT id FROM main.message)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (["session_context_epoch", "session_input", "session_message", "session_share", "todo"].includes(table)) {
|
||||||
|
output.run(
|
||||||
|
`INSERT INTO main."${table}" SELECT * FROM source."${table}" WHERE session_id IN (${placeholders})`,
|
||||||
|
...selected,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output.run(`INSERT INTO main."${table}" SELECT * FROM source."${table}"`)
|
||||||
|
}
|
||||||
|
output.run("DETACH DATABASE source")
|
||||||
|
schema.filter((item) => item.type !== "table").forEach((item) => output.run(item.sql))
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fingerprint(file: string) {
|
||||||
|
const input = Bun.file(file)
|
||||||
|
const hasher = new Bun.CryptoHasher("sha256")
|
||||||
|
for await (const chunk of input.stream()) hasher.update(chunk)
|
||||||
|
return { bytes: input.size, sha256: hasher.digest("hex") }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCorpus(options: Options) {
|
||||||
|
const database = new Database(options.database, { readonly: true })
|
||||||
|
database.run("PRAGMA query_only = ON")
|
||||||
|
const sessions = database
|
||||||
|
.query(
|
||||||
|
`SELECT id, project_id AS projectID, directory, title
|
||||||
|
FROM session AS candidate
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM message
|
||||||
|
WHERE session_id = candidate.id AND time_created >= ? AND time_created < ?
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.all(options.windowStart, options.windowEnd) as { id: string; projectID: string; directory: string; title: string }[]
|
||||||
|
const messageRows = database.query(
|
||||||
|
`SELECT id, data FROM message
|
||||||
|
WHERE session_id = ? AND time_created >= ? AND time_created < ?
|
||||||
|
ORDER BY time_created, id`,
|
||||||
|
)
|
||||||
|
const partRows = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`)
|
||||||
|
const ranked = sessions
|
||||||
|
.map((session) => {
|
||||||
|
const messages = messageRows.all(session.id, options.windowStart, options.windowEnd) as {
|
||||||
|
id: string
|
||||||
|
data: string
|
||||||
|
}[]
|
||||||
|
const parts = messages.flatMap((message) => partRows.all(message.id) as { data: string }[])
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
bytes:
|
||||||
|
messages.reduce((sum, message) => sum + Buffer.byteLength(message.data), 0) +
|
||||||
|
parts.reduce((sum, part) => sum + Buffer.byteLength(part.data), 0),
|
||||||
|
messages: messages.length,
|
||||||
|
parts: parts.length,
|
||||||
|
userTurns: messages.filter((message) => JSON.parse(message.data).role === "user").length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((session) => session.messages > 0)
|
||||||
|
.sort((a, b) => a.bytes - b.bytes || a.id.localeCompare(b.id))
|
||||||
|
if (ranked.length === 0) throw new Error("No sessions found in the profile window")
|
||||||
|
const select = (label: Target["label"], percentile: number) => ({
|
||||||
|
label,
|
||||||
|
...ranked[Math.max(0, Math.ceil(ranked.length * percentile) - 1)]!,
|
||||||
|
})
|
||||||
|
const targets = [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[]
|
||||||
|
const typingText = loadTypingText(database, partRows, messageRows, targets[2]!, options)
|
||||||
|
const projectIDs = [...new Set(ranked.map((session) => session.projectID))]
|
||||||
|
database.close()
|
||||||
|
return { targets, typingText, projectIDs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTypingText(
|
||||||
|
database: Database,
|
||||||
|
partRows: ReturnType<Database["query"]>,
|
||||||
|
messageRows: ReturnType<Database["query"]>,
|
||||||
|
target: Target,
|
||||||
|
options: Options,
|
||||||
|
) {
|
||||||
|
const messages = messageRows.all(target.id, options.windowStart, options.windowEnd) as { id: string; data: string }[]
|
||||||
|
const text = messages
|
||||||
|
.filter((message) => JSON.parse(message.data).role === "user")
|
||||||
|
.flatMap((message) =>
|
||||||
|
(partRows.all(message.id) as { data: string }[]).flatMap((part) => {
|
||||||
|
const data = JSON.parse(part.data)
|
||||||
|
return data.type === "text" && typeof data.text === "string" ? [data.text] : []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.length - a.length)[0]
|
||||||
|
if (!text) throw new Error("No real user prompt found for composer profiling")
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import type { Options } from "./types"
|
||||||
|
|
||||||
|
export async function prepareDesktopState(
|
||||||
|
options: Options,
|
||||||
|
databasePath: string,
|
||||||
|
userData: string,
|
||||||
|
run: number,
|
||||||
|
projectIDs: string[],
|
||||||
|
) {
|
||||||
|
const database = new Database(databasePath)
|
||||||
|
const projects = database.query("SELECT id, worktree, sandboxes FROM project ORDER BY id").all() as {
|
||||||
|
id: string
|
||||||
|
worktree: string
|
||||||
|
sandboxes: string
|
||||||
|
}[]
|
||||||
|
const selected = new Set(projectIDs)
|
||||||
|
const profileProjects = projects.filter((project) => selected.has(project.id))
|
||||||
|
const worktrees =
|
||||||
|
options.mode === "partial-snapshot"
|
||||||
|
? await remapDirectories(database, profileProjects, path.join(options.output, "workspaces", String(run)))
|
||||||
|
: profileProjects.map((project) => project.worktree)
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(userData, "opencode.settings"),
|
||||||
|
JSON.stringify({ firstLaunchOnboardingComplete: true, oldLayoutEligible: true, tauriMigrated: true }),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(userData, "opencode.global.dat"),
|
||||||
|
JSON.stringify({
|
||||||
|
server: JSON.stringify({
|
||||||
|
list: [],
|
||||||
|
projects: { local: worktrees.map((worktree) => ({ worktree, expanded: true })) },
|
||||||
|
lastProject: worktrees[0] ? { local: worktrees[0] } : {},
|
||||||
|
recentlyClosed: {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remapDirectories(
|
||||||
|
database: Database,
|
||||||
|
projects: { id: string; worktree: string; sandboxes: string }[],
|
||||||
|
root: string,
|
||||||
|
) {
|
||||||
|
await mkdir(root, { recursive: true })
|
||||||
|
const mappings = new Map<string, string>()
|
||||||
|
const worktrees = await Promise.all(
|
||||||
|
projects.map(async (project, index) => {
|
||||||
|
const worktree = path.join(root, `project-${String(index + 1).padStart(3, "0")}`)
|
||||||
|
await mkdir(worktree, { recursive: true })
|
||||||
|
mappings.set(project.worktree, worktree)
|
||||||
|
const sandboxes = JSON.parse(project.sandboxes) as string[]
|
||||||
|
const nextSandboxes = await Promise.all(
|
||||||
|
sandboxes.map(async (sandbox, sandboxIndex) => {
|
||||||
|
const next = path.join(worktree, `sandbox-${sandboxIndex + 1}`)
|
||||||
|
await mkdir(next, { recursive: true })
|
||||||
|
mappings.set(sandbox, next)
|
||||||
|
return next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
database.run("UPDATE project SET worktree = ?, sandboxes = ? WHERE id = ?", worktree, JSON.stringify(nextSandboxes), project.id)
|
||||||
|
return worktree
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const byProject = new Map(projects.map((project, index) => [project.id, worktrees[index]!]))
|
||||||
|
const sessions = database.query("SELECT id, project_id, directory FROM session").all() as {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
directory: string
|
||||||
|
}[]
|
||||||
|
const directories = database.query("SELECT * FROM project_directory").all() as {
|
||||||
|
project_id: string
|
||||||
|
directory: string
|
||||||
|
type: string | null
|
||||||
|
strategy: string | null
|
||||||
|
time_created: number
|
||||||
|
}[]
|
||||||
|
const selected = new Set(projects.map((project) => project.id))
|
||||||
|
const nextDirectories = await Promise.all(
|
||||||
|
directories.filter((item) => selected.has(item.project_id)).map(async (item, index) => {
|
||||||
|
const directory =
|
||||||
|
mappings.get(item.directory) ?? path.join(byProject.get(item.project_id) ?? root, `directory-${index + 1}`)
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
return { ...item, directory }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
database.transaction(() => {
|
||||||
|
sessions.filter((session) => selected.has(session.project_id)).forEach((session) =>
|
||||||
|
database.run(
|
||||||
|
"UPDATE session SET directory = ? WHERE id = ?",
|
||||||
|
mappings.get(session.directory) ?? byProject.get(session.project_id) ?? worktrees[0]!,
|
||||||
|
session.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
database.run(
|
||||||
|
`DELETE FROM project_directory WHERE project_id IN (${projects.map(() => "?").join(",")})`,
|
||||||
|
...projects.map((project) => project.id),
|
||||||
|
)
|
||||||
|
nextDirectories.forEach((item) =>
|
||||||
|
database.run(
|
||||||
|
`INSERT INTO project_directory (project_id, directory, type, strategy, time_created)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
item.project_id,
|
||||||
|
item.directory,
|
||||||
|
item.type,
|
||||||
|
item.strategy,
|
||||||
|
item.time_created,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
return worktrees
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import { afterAll, expect, test } from "bun:test"
|
||||||
|
import { mkdir, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createPartialSnapshot, fingerprint } from "./corpus"
|
||||||
|
import { parseOptions } from "./options"
|
||||||
|
|
||||||
|
const directory = path.join(import.meta.dir, `.tmp-${process.pid}`)
|
||||||
|
const source = path.join(directory, "source.db")
|
||||||
|
const partialSnapshot = path.join(directory, "partial-snapshot.db")
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
const database = new Database(source, { create: true })
|
||||||
|
database.run("CREATE TABLE sample (value TEXT NOT NULL)")
|
||||||
|
database.run("INSERT INTO sample VALUES ('repeatable')")
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
afterAll(() => rm(directory, { recursive: true, force: true }))
|
||||||
|
|
||||||
|
test("parses a portable fixed-window partial snapshot invocation", () => {
|
||||||
|
const options = parseOptions([
|
||||||
|
"--mode",
|
||||||
|
"partial-snapshot",
|
||||||
|
"--db",
|
||||||
|
source,
|
||||||
|
"--window-end",
|
||||||
|
"2026-08-04T06:14:26.878Z",
|
||||||
|
"--window-hours",
|
||||||
|
"24",
|
||||||
|
"--scenarios",
|
||||||
|
"home,calibration",
|
||||||
|
"--runs",
|
||||||
|
"3",
|
||||||
|
"--skip-build",
|
||||||
|
])!
|
||||||
|
|
||||||
|
expect(options.database).toBe(source)
|
||||||
|
expect(options.windowEnd).toBe(1_785_824_066_878)
|
||||||
|
expect(options.windowStart).toBe(1_785_737_666_878)
|
||||||
|
expect(options.scenarios).toEqual(["home", "calibration"])
|
||||||
|
expect(options.runs).toBe(3)
|
||||||
|
expect(options.build).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates a consistent private partial database snapshot", async () => {
|
||||||
|
const options = parseOptions(["--db", source, "--window-end", "2026-08-04T06:14:26.878Z"])!
|
||||||
|
await createPartialSnapshot(source, partialSnapshot, options, [])
|
||||||
|
const copy = new Database(partialSnapshot, { readonly: true })
|
||||||
|
expect(copy.query("SELECT value FROM sample").get()).toEqual({ value: "repeatable" })
|
||||||
|
copy.close()
|
||||||
|
expect(await fingerprint(partialSnapshot)).toEqual({
|
||||||
|
bytes: expect.any(Number),
|
||||||
|
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { scenarios, type Options, type Scenario } from "./types"
|
||||||
|
|
||||||
|
const help = `Desktop renderer profiler
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
bun run profile:desktop [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--mode local|partial-snapshot
|
||||||
|
Local corpus or fixed partial snapshot (default: local)
|
||||||
|
--db <path> SQLite database (default: opencode data directory)
|
||||||
|
--partial-snapshot-out <path>
|
||||||
|
Copy the benchmark corpus to a private partial snapshot
|
||||||
|
--output <directory> Report directory (default: OS temp directory)
|
||||||
|
--window-end <ISO|epoch> End of corpus window (default: now; required for partial snapshot)
|
||||||
|
--window-hours <hours> Corpus window size (default: 24)
|
||||||
|
--scenarios <names> Comma list: ${scenarios.join(",")} (default: all)
|
||||||
|
--runs <count> Restart Electron and repeat (default: 1)
|
||||||
|
--skip-build Use the existing desktop production build
|
||||||
|
--diagnostics Capture Chrome traces
|
||||||
|
--cpu Capture sampled CPU summaries
|
||||||
|
--response-urls Attribute Response.text durations by URL
|
||||||
|
--help Show this message
|
||||||
|
|
||||||
|
Partial snapshots contain private application data. Do not commit or share them.
|
||||||
|
`
|
||||||
|
|
||||||
|
export function parseOptions(args: string[], now = Date.now()): Options | undefined {
|
||||||
|
if (args.includes("--help")) {
|
||||||
|
console.log(help)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = (name: string) => {
|
||||||
|
const index = args.indexOf(name)
|
||||||
|
if (index === -1) return
|
||||||
|
const result = args[index + 1]
|
||||||
|
if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const mode = value("--mode") ?? "local"
|
||||||
|
if (mode !== "local" && mode !== "partial-snapshot") throw new Error(`Unsupported mode: ${mode}`)
|
||||||
|
const endValue = value("--window-end")
|
||||||
|
if (mode === "partial-snapshot" && !endValue)
|
||||||
|
throw new Error("--window-end is required in partial-snapshot mode")
|
||||||
|
const windowEnd = endValue ? parseTime(endValue) : now
|
||||||
|
const windowHours = number(value("--window-hours") ?? "24", "--window-hours")
|
||||||
|
const selected = (value("--scenarios")?.split(",") ?? [...scenarios]).map((item) => item.trim())
|
||||||
|
if (selected.some((item) => !scenarios.includes(item as Scenario)))
|
||||||
|
throw new Error(`--scenarios must contain only: ${scenarios.join(", ")}`)
|
||||||
|
const database = path.resolve(value("--db") ?? path.join(Global.Path.data, "opencode.db"))
|
||||||
|
if (!existsSync(database)) throw new Error(`Database does not exist: ${database}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
database,
|
||||||
|
output: path.resolve(
|
||||||
|
value("--output") ?? path.join(tmpdir(), "opencode-performance", new Date(windowEnd).toISOString().replace(/[:.]/g, "-")),
|
||||||
|
),
|
||||||
|
windowStart: windowEnd - windowHours * 60 * 60 * 1_000,
|
||||||
|
windowEnd,
|
||||||
|
scenarios: selected as Scenario[],
|
||||||
|
runs: number(value("--runs") ?? "1", "--runs"),
|
||||||
|
build: !args.includes("--skip-build"),
|
||||||
|
diagnostics: args.includes("--diagnostics"),
|
||||||
|
cpu: args.includes("--cpu"),
|
||||||
|
responseURLs: args.includes("--response-urls"),
|
||||||
|
partialSnapshotOut: value("--partial-snapshot-out")
|
||||||
|
? path.resolve(value("--partial-snapshot-out")!)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(value: string) {
|
||||||
|
const result = /^\d+$/.test(value) ? Number(value) : Date.parse(value)
|
||||||
|
if (!Number.isFinite(result)) throw new Error(`Invalid --window-end: ${value}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value: string, option: string) {
|
||||||
|
const result = Number(value)
|
||||||
|
if (!Number.isFinite(result) || result <= 0) throw new Error(`${option} must be greater than zero`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import type { Options, ProbeResult } from "./types"
|
||||||
|
|
||||||
|
export async function installProbe(page: Page, options: Options) {
|
||||||
|
await page.addInitScript((attributeResponses) => {
|
||||||
|
const state = {
|
||||||
|
longTasks: [] as number[],
|
||||||
|
animationFrames: [] as ProbeResult["animationFrames"],
|
||||||
|
frameGaps: [] as number[],
|
||||||
|
responseText: [] as ProbeResult["responseText"],
|
||||||
|
}
|
||||||
|
;(window as Window & { __opencodeRendererProfile?: typeof state }).__opencodeRendererProfile = state
|
||||||
|
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
|
||||||
|
new PerformanceObserver((list) =>
|
||||||
|
state.longTasks.push(...list.getEntries().map((entry) => entry.duration)),
|
||||||
|
).observe({ type: "longtask" })
|
||||||
|
}
|
||||||
|
if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
|
||||||
|
new PerformanceObserver((list) =>
|
||||||
|
state.animationFrames.push(
|
||||||
|
...list.getEntries().map((entry) => {
|
||||||
|
const frame = entry as PerformanceEntry & {
|
||||||
|
blockingDuration: number
|
||||||
|
scripts?: {
|
||||||
|
duration: number
|
||||||
|
forcedStyleAndLayoutDuration?: number
|
||||||
|
sourceFunctionName?: string
|
||||||
|
sourceURL?: string
|
||||||
|
sourceCharPosition?: number
|
||||||
|
invoker?: string
|
||||||
|
invokerType?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
duration: frame.duration,
|
||||||
|
blockingDuration: frame.blockingDuration,
|
||||||
|
forcedStyleAndLayoutDuration:
|
||||||
|
frame.scripts?.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration ?? 0), 0) ?? 0,
|
||||||
|
scripts:
|
||||||
|
frame.scripts?.map((script) => ({
|
||||||
|
function: script.sourceFunctionName || "(anonymous)",
|
||||||
|
source: script.sourceURL?.split("/").at(-1) || "(document)",
|
||||||
|
position: script.sourceCharPosition ?? -1,
|
||||||
|
invoker: script.invoker ?? "(unknown)",
|
||||||
|
invokerType: script.invokerType ?? "(unknown)",
|
||||||
|
duration: script.duration,
|
||||||
|
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration ?? 0,
|
||||||
|
})) ?? [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).observe({ type: "long-animation-frame" })
|
||||||
|
}
|
||||||
|
let previous = performance.now()
|
||||||
|
const frame = (now: number) => {
|
||||||
|
const gap = now - previous
|
||||||
|
if (gap > 20) state.frameGaps.push(gap)
|
||||||
|
previous = now
|
||||||
|
requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame)
|
||||||
|
if (!attributeResponses) return
|
||||||
|
const responseText = Response.prototype.text
|
||||||
|
Response.prototype.text = function () {
|
||||||
|
const started = performance.now()
|
||||||
|
const url = this.url
|
||||||
|
return responseText.call(this).then((text) => {
|
||||||
|
state.responseText.push({ url, duration: performance.now() - started })
|
||||||
|
return text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, options.responseURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetProbe(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const state = (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile
|
||||||
|
if (!state) return
|
||||||
|
state.longTasks.length = 0
|
||||||
|
state.animationFrames.length = 0
|
||||||
|
state.frameGaps.length = 0
|
||||||
|
state.responseText.length = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectProbe(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeProbe(probe: ProbeResult) {
|
||||||
|
const scripts = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
function: string
|
||||||
|
source: string
|
||||||
|
position: number
|
||||||
|
invoker: string
|
||||||
|
invokerType: string
|
||||||
|
durationMs: number
|
||||||
|
forcedStyleAndLayoutMs: number
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
probe.animationFrames
|
||||||
|
.flatMap((frame) => frame.scripts)
|
||||||
|
.forEach((script) => {
|
||||||
|
const key = `${script.source}:${script.position}:${script.invoker}`
|
||||||
|
const current = scripts.get(key) ?? {
|
||||||
|
function: script.function,
|
||||||
|
source: script.source,
|
||||||
|
position: script.position,
|
||||||
|
invoker: script.invoker,
|
||||||
|
invokerType: script.invokerType,
|
||||||
|
durationMs: 0,
|
||||||
|
forcedStyleAndLayoutMs: 0,
|
||||||
|
}
|
||||||
|
current.durationMs += script.duration
|
||||||
|
current.forcedStyleAndLayoutMs += script.forcedStyleAndLayoutDuration
|
||||||
|
scripts.set(key, current)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
longTasks: {
|
||||||
|
count: probe.longTasks.length,
|
||||||
|
totalMs: sum(probe.longTasks),
|
||||||
|
maxMs: Math.max(0, ...probe.longTasks),
|
||||||
|
},
|
||||||
|
longAnimationFrames: {
|
||||||
|
count: probe.animationFrames.length,
|
||||||
|
totalBlockingMs: sum(probe.animationFrames.map((frame) => frame.blockingDuration)),
|
||||||
|
maxDurationMs: Math.max(0, ...probe.animationFrames.map((frame) => frame.duration)),
|
||||||
|
forcedStyleAndLayoutMs: sum(probe.animationFrames.map((frame) => frame.forcedStyleAndLayoutDuration)),
|
||||||
|
scripts: [...scripts.values()].sort((a, b) => b.durationMs - a.durationMs).slice(0, 15),
|
||||||
|
},
|
||||||
|
frameGaps: {
|
||||||
|
count: probe.frameGaps.length,
|
||||||
|
maxMs: Math.max(0, ...probe.frameGaps),
|
||||||
|
},
|
||||||
|
responseText: probe.responseText
|
||||||
|
.map((item) => ({ path: responsePath(item.url), durationMs: item.duration }))
|
||||||
|
.sort((a, b) => b.durationMs - a.durationMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCPUProfile(page: Page, enabled: boolean) {
|
||||||
|
if (!enabled) return { stop: async () => [] }
|
||||||
|
const session = await page.context().newCDPSession(page)
|
||||||
|
await session.send("Profiler.enable")
|
||||||
|
await session.send("Profiler.setSamplingInterval", { interval: 1_000 })
|
||||||
|
await session.send("Profiler.start")
|
||||||
|
return {
|
||||||
|
async stop() {
|
||||||
|
const result = await session.send("Profiler.stop")
|
||||||
|
await session.detach()
|
||||||
|
const self = new Map<number, number>()
|
||||||
|
result.profile.samples?.forEach((id, index) => {
|
||||||
|
self.set(id, (self.get(id) ?? 0) + (result.profile.timeDeltas?.[index] ?? 0) / 1_000)
|
||||||
|
})
|
||||||
|
return result.profile.nodes
|
||||||
|
.map((node) => ({
|
||||||
|
function: node.callFrame.functionName || "(anonymous)",
|
||||||
|
source: sourceName(node.callFrame.url),
|
||||||
|
line: node.callFrame.lineNumber + 1,
|
||||||
|
selfMs: self.get(node.id) ?? 0,
|
||||||
|
}))
|
||||||
|
.filter((node) => node.selfMs >= 1)
|
||||||
|
.sort((a, b) => b.selfMs - a.selfMs)
|
||||||
|
.slice(0, 40)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function responsePath(value: string) {
|
||||||
|
try {
|
||||||
|
return new URL(value).pathname
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceName(value: string) {
|
||||||
|
if (!value) return "(native)"
|
||||||
|
try {
|
||||||
|
return new URL(value).pathname.split("/").at(-1) || "(document)"
|
||||||
|
} catch {
|
||||||
|
return value.split(/[\\/]/).at(-1) || value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(values: number[]) {
|
||||||
|
return values.reduce((total, value) => total + value, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const started = performance.now()
|
||||||
|
|
||||||
|
export function progress(message: string, details?: Record<string, unknown>) {
|
||||||
|
const elapsed = ((performance.now() - started) / 1_000).toFixed(1)
|
||||||
|
const suffix = details ? ` ${JSON.stringify(details)}` : ""
|
||||||
|
console.error(`[desktop-profile +${elapsed}s] ${message}${suffix}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { chromium, type Page } from "@playwright/test"
|
||||||
|
import { copyFile, mkdir, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { prepareDesktopState } from "./desktop-state"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
import type { Options } from "./types"
|
||||||
|
|
||||||
|
export async function withDesktop<T>(
|
||||||
|
options: Options,
|
||||||
|
desktop: string,
|
||||||
|
run: number,
|
||||||
|
projectIDs: string[],
|
||||||
|
use: (page: Page) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const port = availablePort()
|
||||||
|
const endpoint = `http://127.0.0.1:${port}`
|
||||||
|
const userData = path.join(options.output, `user-data-${run}`)
|
||||||
|
const database =
|
||||||
|
options.mode === "partial-snapshot" ? path.join(options.output, `working-database-${run}.db`) : options.database
|
||||||
|
await rm(userData, { recursive: true, force: true })
|
||||||
|
await mkdir(userData, { recursive: true })
|
||||||
|
if (database !== options.database) await copyFile(options.database, database)
|
||||||
|
await prepareDesktopState(options, database, userData, run, projectIDs)
|
||||||
|
const electron = path.join(
|
||||||
|
desktop,
|
||||||
|
"node_modules",
|
||||||
|
"electron",
|
||||||
|
"dist",
|
||||||
|
(await Bun.file(path.join(desktop, "node_modules", "electron", "path.txt")).text()).trim(),
|
||||||
|
)
|
||||||
|
progress("launching Electron", { run, port })
|
||||||
|
const child = Bun.spawn([electron, "."], {
|
||||||
|
cwd: desktop,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_DB: database,
|
||||||
|
OPENCODE_CHANNEL: "dev",
|
||||||
|
OPENCODE_PROFILE_LOAF: "1",
|
||||||
|
OPENCODE_PROFILE_CDP_PORT: String(port),
|
||||||
|
OPENCODE_PROFILE_USER_DATA: userData,
|
||||||
|
OPENCODE_PERFORMANCE_TRACE_DIR: options.diagnostics ? path.join(options.output, "traces", String(run)) : "",
|
||||||
|
OPENCODE_PERFORMANCE_RUN_ID: `desktop-${run}`,
|
||||||
|
},
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const stdout = drain(child.stdout, "stdout")
|
||||||
|
const stderr = drain(child.stderr, "stderr")
|
||||||
|
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
progress("waiting for CDP", { run })
|
||||||
|
await waitForCDP(endpoint, child, stdout, stderr)
|
||||||
|
progress("connecting Playwright", { run })
|
||||||
|
browser = await chromium.connectOverCDP(endpoint)
|
||||||
|
progress("waiting for renderer", { run })
|
||||||
|
const page = await waitForRenderer(browser)
|
||||||
|
progress("waiting for desktop API", { run })
|
||||||
|
await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 })
|
||||||
|
progress("desktop ready", { run })
|
||||||
|
return await use(page)
|
||||||
|
} finally {
|
||||||
|
progress("stopping Electron", { run })
|
||||||
|
await browser?.close().catch(() => {})
|
||||||
|
await killTree(child.pid)
|
||||||
|
await Promise.allSettled([stdout, stderr])
|
||||||
|
if (database !== options.database) {
|
||||||
|
await Bun.sleep(500)
|
||||||
|
await rm(database, { force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(command: string[], cwd: string, database: string) {
|
||||||
|
const child = Bun.spawn(command, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, OPENCODE_DB: database, OPENCODE_CHANNEL: "dev" },
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
const code = await child.exited
|
||||||
|
if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function availablePort() {
|
||||||
|
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||||
|
const port = server.port
|
||||||
|
server.stop(true)
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCDP(
|
||||||
|
endpoint: string,
|
||||||
|
child: ReturnType<typeof Bun.spawn>,
|
||||||
|
stdout: Promise<string>,
|
||||||
|
stderr: Promise<string>,
|
||||||
|
) {
|
||||||
|
const timeout = Date.now() + 5 * 60_000
|
||||||
|
let heartbeat = Date.now() + 10_000
|
||||||
|
while (Date.now() < timeout) {
|
||||||
|
const ready = await fetch(`${endpoint}/json/version`)
|
||||||
|
.then((response) => response.ok)
|
||||||
|
.catch(() => false)
|
||||||
|
if (ready) return
|
||||||
|
if (child.exitCode !== null)
|
||||||
|
throw new Error(`Desktop exited before CDP was ready (${child.exitCode})\n${await stdout}\n${await stderr}`)
|
||||||
|
if (Date.now() >= heartbeat) {
|
||||||
|
progress("still waiting for CDP")
|
||||||
|
heartbeat = Date.now() + 10_000
|
||||||
|
}
|
||||||
|
await Bun.sleep(250)
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for desktop CDP")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRenderer(browser: Awaited<ReturnType<typeof chromium.connectOverCDP>>) {
|
||||||
|
const timeout = Date.now() + 60_000
|
||||||
|
let heartbeat = Date.now() + 10_000
|
||||||
|
while (Date.now() < timeout) {
|
||||||
|
const page = browser
|
||||||
|
.contexts()
|
||||||
|
.flatMap((context) => context.pages())
|
||||||
|
.find((candidate) => candidate.url().startsWith("oc://renderer"))
|
||||||
|
if (page) return page
|
||||||
|
if (Date.now() >= heartbeat) {
|
||||||
|
progress("still waiting for renderer")
|
||||||
|
heartbeat = Date.now() + 10_000
|
||||||
|
}
|
||||||
|
await Bun.sleep(100)
|
||||||
|
}
|
||||||
|
throw new Error("Desktop renderer target was not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(stream: ReadableStream<Uint8Array>, label: string) {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let output = ""
|
||||||
|
let pending = ""
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const text = decoder.decode(chunk, { stream: true })
|
||||||
|
output = (output + text).slice(-50_000)
|
||||||
|
const lines = (pending + text).split(/\r?\n/)
|
||||||
|
pending = lines.pop() ?? ""
|
||||||
|
lines.filter(Boolean).forEach((line) => progress(`Electron ${label}`, { line: line.slice(0, 500) }))
|
||||||
|
}
|
||||||
|
if (pending) progress(`Electron ${label}`, { line: pending.slice(0, 500) })
|
||||||
|
return output + decoder.decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killTree(pid: number) {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
process.kill(pid, "SIGTERM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const child = Bun.spawn(["taskkill", "/pid", String(pid), "/T", "/F"], { stdout: "ignore", stderr: "ignore" })
|
||||||
|
await child.exited
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
|
||||||
|
export async function setDesktopRoute(page: Page, route: string) {
|
||||||
|
await page.evaluate(async (value) => {
|
||||||
|
const api = window.api as typeof window.api & { getWindowID?: () => Promise<string> }
|
||||||
|
const id = (await api.getWindowID?.()) ?? "browser"
|
||||||
|
localStorage.setItem(`opencode.desktop.window.${id}.last-active-url`, value)
|
||||||
|
}, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForQuietDOM(page: Page) {
|
||||||
|
progress("waiting for DOM to settle")
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
let timer = setTimeout(done, 750)
|
||||||
|
const deadline = setTimeout(done, 30_000)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(done, 750)
|
||||||
|
})
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||||
|
function done() {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(deadline)
|
||||||
|
observer.disconnect()
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
progress("DOM settled")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForSelector(page: Page, selector: string, label: string) {
|
||||||
|
progress("waiting for UI", { label })
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(selector, { timeout: 30_000 })
|
||||||
|
} catch (error) {
|
||||||
|
progress("UI wait failed", {
|
||||||
|
label,
|
||||||
|
url: page.url(),
|
||||||
|
body: (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 500),
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
progress("UI ready", { label })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function domCounts(page: Page, review = false) {
|
||||||
|
return page.evaluate((review) => ({
|
||||||
|
elements: document.getElementsByTagName("*").length,
|
||||||
|
...(review
|
||||||
|
? {
|
||||||
|
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||||
|
diffLines: document.querySelectorAll("[data-line]").length,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
timelineRows: document.querySelectorAll("[data-timeline-row]").length,
|
||||||
|
messageRows: document.querySelectorAll("[data-message-id]").length,
|
||||||
|
markdownRoots: document.querySelectorAll('[data-component="markdown"]').length,
|
||||||
|
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||||
|
}),
|
||||||
|
}), review)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sum(values: number[]) {
|
||||||
|
return values.reduce((total, value) => total + value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function percentile(values: number[], quantile: number) {
|
||||||
|
return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { startChromeTrace } from "../chrome-trace"
|
||||||
|
import { collectProbe, resetProbe, startCPUProfile, summarizeProbe } from "./probe"
|
||||||
|
import { progress } from "./progress"
|
||||||
|
import { domCounts, percentile, setDesktopRoute, sum, waitForQuietDOM, waitForSelector } from "./scenario-utils"
|
||||||
|
import type { Options, Target } from "./types"
|
||||||
|
|
||||||
|
export async function runScenarios(page: Page, options: Options, targets: Target[], typingText: string) {
|
||||||
|
const results: unknown[] = []
|
||||||
|
if (options.scenarios.includes("home")) results.push(await profileHome(page, options))
|
||||||
|
if (options.scenarios.includes("calibration")) results.push(await profileCalibration(page))
|
||||||
|
if (options.scenarios.includes("session")) {
|
||||||
|
for (const target of targets) results.push(await profileSession(page, options, target))
|
||||||
|
}
|
||||||
|
if (options.scenarios.some((scenario) => ["composer", "history", "review"].includes(scenario))) {
|
||||||
|
await openSession(page, targets[2]!)
|
||||||
|
}
|
||||||
|
if (options.scenarios.includes("composer")) results.push(await profileComposer(page, options, typingText))
|
||||||
|
if (options.scenarios.includes("history")) results.push(await profileHistory(page, options, targets[2]!))
|
||||||
|
if (options.scenarios.includes("review")) {
|
||||||
|
const review = await profileReview(page, options)
|
||||||
|
if (review) results.push(review)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileHome(page: Page, options: Options) {
|
||||||
|
const measured = await measure(page, options, "home", async () => {
|
||||||
|
await setDesktopRoute(page, "/")
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||||
|
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
return { ...measured, dom: await domCounts(page) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileCalibration(page: Page) {
|
||||||
|
await resetProbe(page)
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(function opencodeProfileCalibration() {
|
||||||
|
const end = performance.now() + 80
|
||||||
|
while (performance.now() < end) {
|
||||||
|
// Deliberate benchmark-only main-thread block.
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => setTimeout(resolve, 100))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { name: "attribution-calibration", ...summarizeProbe(await collectProbe(page)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileSession(page: Page, options: Options, target: Target) {
|
||||||
|
await prepareHome(page)
|
||||||
|
const measured = await measure(page, options, `session-${target.label}`, async () => {
|
||||||
|
await navigateSession(page, target)
|
||||||
|
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
return { ...measured, context: targetContext(target), dom: await domCounts(page) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileComposer(page: Page, options: Options, typingText: string) {
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]').first()
|
||||||
|
await editor.click()
|
||||||
|
await page.keyboard.press("Control+A")
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
const printable = [...typingText].filter((character) => !["\r", "\n", "\t"].includes(character))
|
||||||
|
const measuredText = printable.slice(-120).join("")
|
||||||
|
const prefix = printable.slice(0, -measuredText.length).join("")
|
||||||
|
if (prefix) await page.keyboard.insertText(prefix)
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
const durations: number[] = []
|
||||||
|
const measured = await measure(page, options, "composer-typing", async () => {
|
||||||
|
for (const character of measuredText) {
|
||||||
|
const started = performance.now()
|
||||||
|
await page.keyboard.type(character)
|
||||||
|
durations.push(performance.now() - started)
|
||||||
|
}
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
await page.keyboard.press("Control+A")
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
return {
|
||||||
|
...measured,
|
||||||
|
context: { promptCharacters: printable.length, measuredCharacters: measuredText.length },
|
||||||
|
typing: {
|
||||||
|
totalMs: sum(durations),
|
||||||
|
meanMs: sum(durations) / durations.length,
|
||||||
|
p50Ms: percentile(durations, 0.5),
|
||||||
|
p95Ms: percentile(durations, 0.95),
|
||||||
|
maxMs: Math.max(...durations),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileHistory(page: Page, options: Options, target: Target) {
|
||||||
|
await waitForSelector(page, '[data-component="prompt-input"]', "history session composer")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
let requests = 0
|
||||||
|
const onResponse = (response: { url(): string }) => {
|
||||||
|
if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++
|
||||||
|
}
|
||||||
|
page.on("response", onResponse)
|
||||||
|
const measured = await measure(page, options, "session-max-history-boundary", async () => {
|
||||||
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first()
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
element.scrollTop = 0
|
||||||
|
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -10_000, bubbles: true }))
|
||||||
|
element.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||||
|
})
|
||||||
|
const timeout = Date.now() + 60_000
|
||||||
|
while (requests === 0 && Date.now() < timeout) await page.waitForTimeout(50)
|
||||||
|
if (requests === 0) throw new Error("History boundary did not request a page")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
page.off("response", onResponse)
|
||||||
|
return { ...measured, context: targetContext(target), messageRequests: requests }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profileReview(page: Page, options: Options) {
|
||||||
|
const button = page.getByRole("button", { name: "Toggle review" })
|
||||||
|
if (!(await button.isVisible().catch(() => false))) return
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
if (await panel.isVisible().catch(() => false)) {
|
||||||
|
await button.click()
|
||||||
|
await panel.waitFor({ state: "hidden", timeout: 60_000 })
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
}
|
||||||
|
const measured = await measure(page, options, "review-open", async () => {
|
||||||
|
await button.click()
|
||||||
|
await panel.waitFor({ state: "visible", timeout: 60_000 })
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
})
|
||||||
|
return { ...measured, dom: await domCounts(page, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measure(page: Page, options: Options, name: string, action: () => Promise<void>) {
|
||||||
|
progress("scenario started", { name })
|
||||||
|
await resetProbe(page)
|
||||||
|
const stopTrace = options.diagnostics ? await startChromeTrace(page, name) : undefined
|
||||||
|
const cpu = await startCPUProfile(page, options.cpu)
|
||||||
|
const started = performance.now()
|
||||||
|
await action()
|
||||||
|
const result = {
|
||||||
|
name,
|
||||||
|
elapsedMs: performance.now() - started,
|
||||||
|
...summarizeProbe(await collectProbe(page)),
|
||||||
|
cpu: await cpu.stop(),
|
||||||
|
trace: await stopTrace?.(),
|
||||||
|
}
|
||||||
|
progress("scenario completed", { name, elapsedMs: Math.round(result.elapsedMs), longTasks: result.longTasks.count })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSession(page: Page, target: Target) {
|
||||||
|
await navigateSession(page, target)
|
||||||
|
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareHome(page: Page) {
|
||||||
|
await setDesktopRoute(page, "/")
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||||
|
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
|
||||||
|
await waitForQuietDOM(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateSession(page: Page, target: Target) {
|
||||||
|
await setDesktopRoute(page, `/server/${base64Encode("sidecar")}/session/${target.id}`)
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetContext(target: Target) {
|
||||||
|
return { serializedBytes: target.bytes, messages: target.messages, parts: target.parts, userTurns: target.userTurns }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const scenarios = ["home", "calibration", "session", "composer", "history", "review"] as const
|
||||||
|
|
||||||
|
export type Scenario = (typeof scenarios)[number]
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
mode: "local" | "partial-snapshot"
|
||||||
|
database: string
|
||||||
|
output: string
|
||||||
|
windowStart: number
|
||||||
|
windowEnd: number
|
||||||
|
scenarios: Scenario[]
|
||||||
|
runs: number
|
||||||
|
build: boolean
|
||||||
|
diagnostics: boolean
|
||||||
|
cpu: boolean
|
||||||
|
responseURLs: boolean
|
||||||
|
partialSnapshotOut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Target = {
|
||||||
|
label: "p50" | "p95" | "max"
|
||||||
|
id: string
|
||||||
|
projectID: string
|
||||||
|
directory: string
|
||||||
|
title: string
|
||||||
|
bytes: number
|
||||||
|
messages: number
|
||||||
|
parts: number
|
||||||
|
userTurns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProbeResult = {
|
||||||
|
longTasks: number[]
|
||||||
|
animationFrames: {
|
||||||
|
duration: number
|
||||||
|
blockingDuration: number
|
||||||
|
forcedStyleAndLayoutDuration: number
|
||||||
|
scripts: {
|
||||||
|
function: string
|
||||||
|
source: string
|
||||||
|
position: number
|
||||||
|
invoker: string
|
||||||
|
invokerType: string
|
||||||
|
duration: number
|
||||||
|
forcedStyleAndLayoutDuration: number
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
frameGaps: number[]
|
||||||
|
responseText: { url: string; duration: number }[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createPartialSnapshot, fingerprint, loadCorpus } from "./desktop-profile/corpus"
|
||||||
|
import { parseOptions } from "./desktop-profile/options"
|
||||||
|
import { installProbe } from "./desktop-profile/probe"
|
||||||
|
import { progress } from "./desktop-profile/progress"
|
||||||
|
import { withDesktop, run } from "./desktop-profile/runtime"
|
||||||
|
import { runScenarios } from "./desktop-profile/scenarios"
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dir, "../../../..")
|
||||||
|
const desktop = path.join(root, "packages/desktop")
|
||||||
|
const options = parseOptions(process.argv.slice(2))
|
||||||
|
if (!options) process.exit(0)
|
||||||
|
|
||||||
|
await mkdir(options.output, { recursive: true })
|
||||||
|
progress("loading corpus", { mode: options.mode })
|
||||||
|
let corpus = loadCorpus(options)
|
||||||
|
if (options.partialSnapshotOut) {
|
||||||
|
progress("creating partial snapshot")
|
||||||
|
await createPartialSnapshot(options.database, options.partialSnapshotOut, options, corpus.targets)
|
||||||
|
options.database = options.partialSnapshotOut
|
||||||
|
options.mode = "partial-snapshot"
|
||||||
|
corpus = loadCorpus(options)
|
||||||
|
}
|
||||||
|
if (options.build) {
|
||||||
|
progress("building desktop production bundle")
|
||||||
|
await run(["bun", "run", "build"], desktop, options.database)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress("corpus ready", { targets: corpus.targets.map((target) => target.label), runs: options.runs })
|
||||||
|
const runs = []
|
||||||
|
for (let index = 1; index <= options.runs; index++) {
|
||||||
|
runs.push(
|
||||||
|
await withDesktop(options, desktop, index, corpus.projectIDs, async (page) => {
|
||||||
|
await installProbe(page, options)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return runScenarios(page, options, corpus.targets, corpus.typingText)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
source: options.mode === "partial-snapshot" ? "partial-database-snapshot" : "local-opencode-db",
|
||||||
|
command: process.argv.slice(2),
|
||||||
|
diagnostics: options.diagnostics,
|
||||||
|
profileCPU: options.cpu,
|
||||||
|
database: await fingerprint(options.database),
|
||||||
|
window: {
|
||||||
|
start: new Date(options.windowStart).toISOString(),
|
||||||
|
end: new Date(options.windowEnd).toISOString(),
|
||||||
|
},
|
||||||
|
revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(),
|
||||||
|
targets: corpus.targets.map(({ id: _, projectID: __, directory: ___, title: ____, ...target }) => target),
|
||||||
|
summary: summarize(runs),
|
||||||
|
runs: runs.map((results, index) => ({ index: index + 1, results })),
|
||||||
|
}
|
||||||
|
const file = path.join(options.output, "renderer-profile.json")
|
||||||
|
await Bun.write(file, JSON.stringify(report, null, 2))
|
||||||
|
console.log(`PROFILE_REPORT ${file}`)
|
||||||
|
console.log(`PROFILE_SUMMARY ${JSON.stringify(report.summary)}`)
|
||||||
|
console.log(JSON.stringify(report, null, 2))
|
||||||
|
|
||||||
|
function summarize(runs: unknown[][]) {
|
||||||
|
type Result = {
|
||||||
|
name: string
|
||||||
|
elapsedMs?: number
|
||||||
|
longTasks: { count: number; totalMs: number; maxMs: number }
|
||||||
|
longAnimationFrames: { totalBlockingMs: number }
|
||||||
|
typing?: { p50Ms: number; p95Ms: number; maxMs: number }
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
[...Map.groupBy(runs.flat() as Result[], (result) => result.name)].map(([name, samples]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
samples: samples.length,
|
||||||
|
elapsedMedianMs: median(samples.flatMap((sample) => sample.elapsedMs ?? [])),
|
||||||
|
longTasks: {
|
||||||
|
maxCount: Math.max(...samples.map((sample) => sample.longTasks.count)),
|
||||||
|
maxTotalMs: Math.max(...samples.map((sample) => sample.longTasks.totalMs)),
|
||||||
|
maxTaskMs: Math.max(...samples.map((sample) => sample.longTasks.maxMs)),
|
||||||
|
},
|
||||||
|
maxBlockingMs: Math.max(...samples.map((sample) => sample.longAnimationFrames.totalBlockingMs)),
|
||||||
|
...(samples[0]?.typing
|
||||||
|
? {
|
||||||
|
typingMedianMs: {
|
||||||
|
p50: median(samples.flatMap((sample) => sample.typing?.p50Ms ?? [])),
|
||||||
|
p95: median(samples.flatMap((sample) => sample.typing?.p95Ms ?? [])),
|
||||||
|
max: median(samples.flatMap((sample) => sample.typing?.maxMs ?? [])),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]) {
|
||||||
|
if (values.length === 0) return
|
||||||
|
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "./profile-desktop"
|
||||||
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
})
|
})
|
||||||
await mockServer(page, { questions: [] })
|
await mockServer(page, { questions: [] })
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection({ path: "/api/event" })
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
@@ -132,32 +132,40 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.toBe(cursor)
|
.toBe(cursor)
|
||||||
await transport.send({
|
await transport.send(
|
||||||
directory,
|
{
|
||||||
payload: {
|
directory,
|
||||||
type: "question.asked",
|
payload: {
|
||||||
properties: {
|
type: "question.asked",
|
||||||
id: "question-caret",
|
properties: {
|
||||||
sessionID,
|
id: "question-caret",
|
||||||
questions: [
|
sessionID,
|
||||||
{
|
questions: [
|
||||||
header: "Continue",
|
{
|
||||||
question: "Continue?",
|
header: "Continue",
|
||||||
options: [{ label: "Yes", description: "Continue the session" }],
|
question: "Continue?",
|
||||||
},
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
],
|
},
|
||||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
],
|
||||||
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
undefined,
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
await expect(question).toBeVisible()
|
await expect(question).toBeVisible()
|
||||||
await expect(editor).toHaveCount(0)
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
await transport.send({
|
await transport.send(
|
||||||
directory,
|
{
|
||||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
directory,
|
||||||
})
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
await expect(question).toHaveCount(0)
|
await expect(question).toHaveCount(0)
|
||||||
await expect(editor).toBeVisible()
|
await expect(editor).toBeVisible()
|
||||||
await page.keyboard.press("x")
|
await page.keyboard.press("x")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
|||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const initialPageSize = 20
|
const initialPageSize = 20
|
||||||
const historyPageSize = 200
|
const historyPageSize = 50
|
||||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
|
|||||||
@@ -99,13 +99,15 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { protocol: "v2" })
|
const timeline = await setupTimeline(page, { protocol: "v2" })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(
|
||||||
id: "timeline-event-7",
|
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||||
})
|
{ id: "timeline-event-7" },
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
await timeline.waitForPart("prt_transport_id")
|
await timeline.waitForPart("prt_transport_id")
|
||||||
|
|
||||||
await timeline.transport.error("retry with event id")
|
await timeline.transport.error("retry with event id", "/api/event")
|
||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const root = "C:/OpenCode/WorkspaceProject"
|
||||||
|
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||||
|
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||||
|
const project = {
|
||||||
|
id: "proj_workspaces",
|
||||||
|
worktree: root,
|
||||||
|
vcs: "git" as const,
|
||||||
|
name: "workspace-project",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [workspace],
|
||||||
|
}
|
||||||
|
const provider = {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
}
|
||||||
|
const diff = {
|
||||||
|
file: "src/workspace.ts",
|
||||||
|
additions: 3,
|
||||||
|
deletions: 1,
|
||||||
|
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(sessionID: string, id: string, text: string, withDiff = false) {
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "opencode", modelID: "test" },
|
||||||
|
...(withDiff ? { summary: { diffs: [diff] } } : {}),
|
||||||
|
},
|
||||||
|
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(page: Page, tab: Record<string, unknown>) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ root, server, tab }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||||
|
},
|
||||||
|
{ root, server, tab },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspaces"
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /^local$/i }).click()
|
||||||
|
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
|
||||||
|
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||||
|
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspace_settings"
|
||||||
|
const cleanWorkspace = `${workspace}-clean`
|
||||||
|
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||||
|
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project: inventory,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
let releaseSessions = () => {}
|
||||||
|
const sessionsReady = new Promise<void>((resolve) => {
|
||||||
|
releaseSessions = resolve
|
||||||
|
})
|
||||||
|
await page.route("**/session?**", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (route.request().method() !== "GET" || url.pathname !== "/session" || url.searchParams.get("limit") !== "1000")
|
||||||
|
return route.fallback()
|
||||||
|
await sessionsReady
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: "[]",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/worktree**", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "DELETE") return route.fallback()
|
||||||
|
await transport.send({
|
||||||
|
directory: "global",
|
||||||
|
payload: {
|
||||||
|
id: "evt_workspace_deleted_settings",
|
||||||
|
type: "project.updated",
|
||||||
|
properties: { ...inventory, sandboxes: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: "true",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||||
|
|
||||||
|
const app = page.locator('[data-component="session-new-design"]')
|
||||||
|
const appNode = await app.elementHandle()
|
||||||
|
await page.getByRole("button", { name: /local|new workspace/i }).click()
|
||||||
|
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
|
||||||
|
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
|
||||||
|
await page.getByRole("menuitem", { name: "View all" }).click()
|
||||||
|
|
||||||
|
const settings = page.locator(".settings-v2-dialog")
|
||||||
|
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||||
|
expect(await appNode?.evaluate((node) => node.isConnected)).toBe(true)
|
||||||
|
releaseSessions()
|
||||||
|
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||||
|
const confirmation = page
|
||||||
|
.locator('[data-component="dialog-v2"]')
|
||||||
|
.filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||||
|
const removed = page.waitForRequest(
|
||||||
|
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
|
||||||
|
)
|
||||||
|
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
|
||||||
|
const request = await removed
|
||||||
|
expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
|
||||||
|
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
|
||||||
|
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspace_submit"
|
||||||
|
const sessionID = "ses_workspace_submit"
|
||||||
|
const session = {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "workspace-submit",
|
||||||
|
projectID: project.id,
|
||||||
|
directory: createdWorkspace,
|
||||||
|
title: "New session",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
}
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [session],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/worktree**", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "POST") return route.fallback()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/session**", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.pathname !== "/session") return route.fallback()
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "POST") return route.fallback()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify(session),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await page.getByRole("button", { name: /^local$/i }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
|
await editor.fill("Build workspace support")
|
||||||
|
await page.locator('[data-action="prompt-submit"]').click()
|
||||||
|
|
||||||
|
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||||
|
await expect(lifecycle).toContainText("Creating workspace")
|
||||||
|
const sent = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
|
||||||
|
)
|
||||||
|
await transport.send({
|
||||||
|
directory: createdWorkspace,
|
||||||
|
payload: {
|
||||||
|
id: "evt_submit_ready",
|
||||||
|
type: "worktree.ready",
|
||||||
|
properties: { name: "quick-contrast-fix" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await sent
|
||||||
|
await expect(lifecycle).toContainText("Workspace created")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sessionID = "ses_workspace_move_new"
|
||||||
|
const messageID = "msg_workspace_move_new"
|
||||||
|
const session = {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "workspace-move-new",
|
||||||
|
projectID: project.id,
|
||||||
|
directory: root,
|
||||||
|
title: "Create a workspace",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
}
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [session],
|
||||||
|
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
|
||||||
|
vcsDiff: [diff],
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/worktree**", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "POST") return route.fallback()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/experimental/control-plane/move-session", async (route) => {
|
||||||
|
if (route.request().method() === "OPTIONS") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.directory = createdWorkspace
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: "null",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "session", sessionId: sessionID })
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
|
||||||
|
const panel = page.locator('[data-component="session-summary-panel"]')
|
||||||
|
await panel.getByRole("button", { name: "Local repository" }).click()
|
||||||
|
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
|
||||||
|
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||||
|
|
||||||
|
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||||
|
await expect(lifecycle).toContainText("Creating workspace")
|
||||||
|
const moved = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
|
||||||
|
)
|
||||||
|
await transport.send({
|
||||||
|
directory: createdWorkspace,
|
||||||
|
payload: {
|
||||||
|
id: "evt_worktree_ready",
|
||||||
|
type: "worktree.ready",
|
||||||
|
properties: { name: "quick-contrast-fix" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect((await moved).postDataJSON()).toEqual({
|
||||||
|
sessionID,
|
||||||
|
destination: { directory: createdWorkspace },
|
||||||
|
moveChanges: true,
|
||||||
|
})
|
||||||
|
await transport.send({
|
||||||
|
directory: createdWorkspace,
|
||||||
|
payload: {
|
||||||
|
id: "evt_workspace_created",
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sessionID,
|
||||||
|
location: { directory: createdWorkspace },
|
||||||
|
subdirectory: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(lifecycle).toContainText("Workspace created")
|
||||||
|
})
|
||||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
|||||||
|
|
||||||
export type SseTransport<T> = {
|
export type SseTransport<T> = {
|
||||||
server: string
|
server: string
|
||||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
waitForConnection(options?: {
|
||||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
after?: number
|
||||||
|
timeout?: number
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}): Promise<SseConnectionRecord>
|
||||||
|
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||||
close(): Promise<void>
|
close(): Promise<void>
|
||||||
disconnect(message?: string): Promise<void>
|
disconnect(message?: string): Promise<void>
|
||||||
error(message?: string): Promise<void>
|
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||||
connections(): Promise<SseConnectionRecord[]>
|
connections(): Promise<SseConnectionRecord[]>
|
||||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserCommand<T> =
|
type BrowserCommand<T> =
|
||||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
| {
|
||||||
|
type: "send"
|
||||||
|
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||||
|
burst: boolean
|
||||||
|
cuts?: number[]
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}
|
||||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
| {
|
||||||
|
type: "end"
|
||||||
|
mode: "close" | "disconnect" | "error"
|
||||||
|
message?: string
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}
|
||||||
| { type: "connections" }
|
| { type: "connections" }
|
||||||
| { type: "acknowledgements" }
|
| { type: "acknowledgements" }
|
||||||
|
|
||||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
|||||||
let nextConnectionID = 0
|
let nextConnectionID = 0
|
||||||
let nextDeliveryID = 0
|
let nextDeliveryID = 0
|
||||||
|
|
||||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
const current = (path?: SseConnectionRecord["path"]) =>
|
||||||
|
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||||
const boundaries = [...new Set(cuts ?? [])]
|
const boundaries = [...new Set(cuts ?? [])]
|
||||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
|||||||
acknowledgements.push(acknowledgement)
|
acknowledgements.push(acknowledgement)
|
||||||
return acknowledgement
|
return acknowledgement
|
||||||
}
|
}
|
||||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||||
const connection = current()
|
const connection = current(path)
|
||||||
if (!connection) throw new Error("SSE transport has no active connection")
|
if (!connection) throw new Error("SSE transport has no active connection")
|
||||||
connection.endedAt = performance.now()
|
connection.endedAt = performance.now()
|
||||||
connection.endedBy = mode
|
connection.endedBy = mode
|
||||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
|||||||
if (input.type === "connections")
|
if (input.type === "connections")
|
||||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||||
if (input.type === "acknowledgements") return acknowledgements
|
if (input.type === "acknowledgements") return acknowledgements
|
||||||
if (input.type === "end") return end(input.mode, input.message)
|
if (input.type === "end") return end(input.mode, input.message, input.path)
|
||||||
const connection = current()
|
const connection = current(input.type === "send" ? input.path : undefined)
|
||||||
if (!connection) throw new Error("SSE transport has no active connection")
|
if (!connection) throw new Error("SSE transport has no active connection")
|
||||||
if (input.type === "raw") {
|
if (input.type === "raw") {
|
||||||
marker(input.marker)
|
marker(input.marker)
|
||||||
@@ -248,12 +264,15 @@ export async function installSseTransport<T>(
|
|||||||
server,
|
server,
|
||||||
async waitForConnection(input = {}) {
|
async waitForConnection(input = {}) {
|
||||||
const connection = await page.waitForFunction(
|
const connection = await page.waitForFunction(
|
||||||
(after) => {
|
({ after, path }) => {
|
||||||
const transport = (window as BrowserTransport).__testSseTransport
|
const transport = (window as BrowserTransport).__testSseTransport
|
||||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||||
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
|
return connections?.findLast(
|
||||||
|
(connection) =>
|
||||||
|
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
input.after ?? 0,
|
{ after: input.after ?? 0, path: input.path },
|
||||||
{ timeout: input.timeout },
|
{ timeout: input.timeout },
|
||||||
)
|
)
|
||||||
let result: SseConnectionRecord | undefined
|
let result: SseConnectionRecord | undefined
|
||||||
@@ -265,8 +284,8 @@ export async function installSseTransport<T>(
|
|||||||
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
send(payload, eventOptions) {
|
send(payload, eventOptions, path) {
|
||||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||||
},
|
},
|
||||||
burst(payloads, eventOptions = []) {
|
burst(payloads, eventOptions = []) {
|
||||||
return command({
|
return command({
|
||||||
@@ -304,8 +323,8 @@ export async function installSseTransport<T>(
|
|||||||
disconnect(message) {
|
disconnect(message) {
|
||||||
return command({ type: "end", mode: "disconnect", message })
|
return command({ type: "end", mode: "disconnect", message })
|
||||||
},
|
},
|
||||||
error(message) {
|
error(message, path) {
|
||||||
return command({ type: "end", mode: "error", message })
|
return command({ type: "end", mode: "error", message, path })
|
||||||
},
|
},
|
||||||
connections() {
|
connections() {
|
||||||
return command({ type: "connections" })
|
return command({ type: "connections" })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||||
|
"profile:desktop": "bun run e2e/performance/profile-desktop.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
|
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
@@ -58,18 +58,24 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
|||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import LegacyLayout from "@/pages/layout"
|
|
||||||
import NewLayout from "@/pages/layout-new"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
|
||||||
import { NewHome } from "@/pages/home"
|
|
||||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
const NewLayout = lazy(() => import("@/pages/layout-new"))
|
||||||
|
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||||
|
const LegacyLayout = lazy(() => import("@/pages/layout"))
|
||||||
|
const LegacyHome = lazy(() => import("@/pages/home/legacy-home").then((module) => ({ default: module.LegacyHome })))
|
||||||
|
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||||
|
const SessionPage = lazy(() => import("@/pages/session").then((module) => ({ default: module.SessionPage })))
|
||||||
|
const SessionRouteErrorBoundary = lazy(() =>
|
||||||
|
import("@/pages/session").then((module) => ({ default: module.SessionRouteErrorBoundary })),
|
||||||
|
)
|
||||||
|
const TargetSessionRouteContent = lazy(() =>
|
||||||
|
import("@/pages/session").then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||||
|
)
|
||||||
|
|
||||||
const SessionRoute = () => {
|
const SessionRoute = () => {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -235,7 +241,13 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<I18nProvider
|
<I18nProvider
|
||||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
value={{
|
||||||
|
locale: language.intl,
|
||||||
|
layoutLocale: language.layoutLocale,
|
||||||
|
t: language.t as UiI18n["t"],
|
||||||
|
plural: language.plural,
|
||||||
|
pluralForm: language.pluralForm,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
openDelay={0}
|
delay="intent"
|
||||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
openDelay={0}
|
delay="intent"
|
||||||
value={
|
value={
|
||||||
<ModelTooltip
|
<ModelTooltip
|
||||||
model={item}
|
model={item}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
|||||||
import homeImage from "@/assets/help/home.png"
|
import homeImage from "@/assets/help/home.png"
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
|
||||||
const showPopover = () => true
|
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -134,7 +131,6 @@ export function TabsInfoPopup() {
|
|||||||
<p>{language.t("help.tabs.home")}</p>
|
<p>{language.t("help.tabs.home")}</p>
|
||||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
<p>{language.t("help.tabs.persistence")}</p>
|
<p>{language.t("help.tabs.persistence")}</p>
|
||||||
<p>{language.t("help.tabs.worktrees")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
|
accentSubmit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
|
accentSubmit={props.accentSubmit}
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
variantControlVisible={!props.controller.model.loading}
|
variantControlVisible={!props.controller.model.loading}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
openDelay={800}
|
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -53,12 +53,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<For each={props.comments ?? []}>
|
<For each={props.comments ?? []}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="relative group shrink-0">
|
<div class="relative group shrink-0">
|
||||||
<TooltipV2
|
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||||
value={item.comment}
|
|
||||||
placement="top"
|
|
||||||
openDelay={800}
|
|
||||||
contentClass="max-w-[300px] break-words"
|
|
||||||
>
|
|
||||||
<CommentCardV2
|
<CommentCardV2
|
||||||
comment={item.comment ?? ""}
|
comment={item.comment ?? ""}
|
||||||
path={item.path}
|
path={item.path}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||||
import type { ModelSelection } from "@/context/local"
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const sessionCreateInputs: Array<{
|
type SessionCreateInput = {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
location?: { directory: string }
|
location?: { directory: string }
|
||||||
}> = []
|
}
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -22,24 +22,35 @@ const optimistic: Array<{
|
|||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
}> = []
|
}> = []
|
||||||
const optimisticSeeded: boolean[] = []
|
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
|
||||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const sentShellDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
const sentCommands: unknown[] = []
|
const sentCommands: unknown[] = []
|
||||||
const commands: Array<{ name: string }> = []
|
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||||
|
const syncedServers: string[] = []
|
||||||
|
const optimisticServers: string[] = []
|
||||||
|
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
let createWorktreeGate: Promise<void> | undefined
|
||||||
|
let worktreeFailure: Error | undefined
|
||||||
|
let worktreeHung = false
|
||||||
|
let worktreeCreates = 0
|
||||||
|
let activeSDK = "server-a"
|
||||||
|
let activeServerSync = "server-a"
|
||||||
|
let activeDirectorySync = "server-a"
|
||||||
|
let commands: Array<{ name: string }> = []
|
||||||
|
let worktreeDirectory = "/repo/new-0"
|
||||||
|
let worktreeID = 0
|
||||||
|
const draftServers: Record<string, string> = {}
|
||||||
|
|
||||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
@@ -67,19 +78,21 @@ const prompt = {
|
|||||||
replaceComments: () => undefined,
|
replaceComments: () => undefined,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
},
|
},
|
||||||
capture: () => prompt,
|
capture: (scope?: unknown, target?: unknown) => {
|
||||||
|
promptCaptures.push({ scope, target })
|
||||||
|
return prompt
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
|
||||||
return {
|
return {
|
||||||
api: {
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
create: async (input: SessionCreateInput) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
const location = input.location?.directory ?? directory
|
const location = input.location?.directory ?? directory
|
||||||
createdSessions.push(location)
|
createdSessions.push(location)
|
||||||
sessionCreateInputs.push(input)
|
|
||||||
return {
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id: `session-${createdSessions.length}`,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
@@ -102,6 +115,7 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
sentShell.push(input)
|
sentShell.push(input)
|
||||||
|
sentShellDirectories.push(directory)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -110,7 +124,16 @@ const clientFor = (directory: string) => {
|
|||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
worktree: {
|
worktree: {
|
||||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||||
|
worktreeCreates++
|
||||||
|
if (worktreeHung)
|
||||||
|
return new Promise<never>((_, reject) => {
|
||||||
|
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||||
|
})
|
||||||
|
await createWorktreeGate
|
||||||
|
if (worktreeFailure) throw worktreeFailure
|
||||||
|
return { data: { directory: worktreeDirectory } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,13 +150,13 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
createOpencodeClient: (input: { directory: string }) => {
|
createOpencodeClient: (input: { directory: string }) => {
|
||||||
createdClients.push(input.directory)
|
|
||||||
return clientFor(input.directory)
|
return clientFor(input.directory)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
|
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -151,20 +174,13 @@ beforeAll(async () => {
|
|||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
promote(directory: string, sessionID: string) {
|
promote: () => undefined,
|
||||||
promoted.push({ directory, sessionID })
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => {
|
mock.module("@/context/permission", () => {
|
||||||
const state = (server: string) => ({
|
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
enabledAutoAccept.push({ server, sessionID, directory })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
@@ -173,7 +189,10 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/tabs", () => ({
|
mock.module("@/context/tabs", () => ({
|
||||||
useTabs: () => ({
|
useTabs: () => ({
|
||||||
draft: () => ({ server: "project-server" }),
|
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||||
|
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||||
|
updatedDrafts.push({ draftID, ...draft })
|
||||||
|
},
|
||||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||||
promotedDrafts.push({ draftID, ...session })
|
promotedDrafts.push({ draftID, ...session })
|
||||||
},
|
},
|
||||||
@@ -194,72 +213,75 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => {
|
useSDK: () => {
|
||||||
const sdk = {
|
return () => ({
|
||||||
scope: "local",
|
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||||
directory: "/repo/main",
|
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
|
createApi: (directory: string) => clientFor(directory).api,
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
return () => sdk
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => {
|
||||||
data: { command: commands },
|
const server = activeDirectorySync
|
||||||
session: {
|
return {
|
||||||
optimistic: {
|
data: { command: commands },
|
||||||
add: (value: {
|
session: {
|
||||||
directory?: string
|
optimistic: {
|
||||||
sessionID?: string
|
add: (value: {
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
directory?: string
|
||||||
}) => {
|
sessionID?: string
|
||||||
optimistic.push(value)
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
optimisticSeeded.push(
|
}) => {
|
||||||
!!value.directory &&
|
optimisticServers.push(server)
|
||||||
!!value.sessionID &&
|
optimistic.push(value)
|
||||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
},
|
||||||
)
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
remove: () => undefined,
|
|
||||||
},
|
},
|
||||||
},
|
set: () => undefined,
|
||||||
set: () => undefined,
|
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => () => ({
|
useServerSync: () => () => {
|
||||||
session: {
|
const server = activeServerSync
|
||||||
remember: () => undefined,
|
return {
|
||||||
set: () => undefined,
|
session: {
|
||||||
sync: async () => {
|
remember: () => undefined,
|
||||||
serverSessionSyncs++
|
set: () => undefined,
|
||||||
},
|
sync: async () => {
|
||||||
},
|
serverSessionSyncs++
|
||||||
child: (directory: string) => {
|
|
||||||
syncedDirectories.push(directory)
|
|
||||||
storedSessions[directory] ??= []
|
|
||||||
return [
|
|
||||||
{ session: storedSessions[directory] },
|
|
||||||
(...args: unknown[]) => {
|
|
||||||
if (args[0] !== "session") return
|
|
||||||
const next = args[1]
|
|
||||||
if (typeof next === "function") {
|
|
||||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Array.isArray(next)) {
|
|
||||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
},
|
child: (directory: string) => {
|
||||||
}),
|
syncedServers.push(server)
|
||||||
|
storedSessions[directory] ??= []
|
||||||
|
return [
|
||||||
|
{ session: storedSessions[directory] },
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
if (args[0] !== "session") return
|
||||||
|
const next = args[1]
|
||||||
|
if (typeof next === "function") {
|
||||||
|
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(next)) {
|
||||||
|
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
@@ -279,198 +301,153 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
sessionCreateInputs.length = 0
|
|
||||||
enabledAutoAccept.length = 0
|
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
|
||||||
promoted.length = 0
|
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
updatedDrafts.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
sentCommands.length = 0
|
syncedServers.length = 0
|
||||||
commands.length = 0
|
optimisticServers.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
promptCaptures.length = 0
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
sentShellDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
activeSDK = "server-a"
|
||||||
|
activeServerSync = "server-a"
|
||||||
|
activeDirectorySync = "server-a"
|
||||||
|
commands = []
|
||||||
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
serverSessionSyncs = 0
|
serverSessionSyncs = 0
|
||||||
|
createWorktreeGate = undefined
|
||||||
|
worktreeFailure = undefined
|
||||||
|
worktreeHung = false
|
||||||
|
worktreeCreates = 0
|
||||||
|
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||||
|
createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("admits only one concurrent new-workspace submission", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
let release = () => {}
|
||||||
info: () => undefined,
|
createWorktreeGate = new Promise<void>((resolve) => {
|
||||||
imageAttachments: () => [],
|
release = resolve
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
const submit = makeSubmit()
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const first = submit.handleSubmit(event)
|
||||||
|
const duplicate = submit.handleSubmit(event)
|
||||||
|
expect(worktreeCreates).toBe(1)
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
release()
|
||||||
selected = "/repo/worktree-b"
|
await Promise.all([first, duplicate])
|
||||||
await submit.handleSubmit(event)
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(worktreeCreates).toBe(1)
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toHaveLength(1)
|
||||||
expect(sessionCreateInputs).toEqual([
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
{
|
|
||||||
agent: "agent",
|
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-a" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
agent: "agent",
|
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-b" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(sentShell).toEqual([
|
|
||||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
|
||||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
|
||||||
])
|
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
|
||||||
expect(serverSessionSyncs).toBe(0)
|
|
||||||
expect(promoted).toEqual([
|
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
|
||||||
])
|
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
worktreeHung = true
|
||||||
info: () => undefined,
|
let resets = 0
|
||||||
imageAttachments: () => [],
|
const submit = makeSubmit({
|
||||||
commentCount: () => 0,
|
onNewSessionWorktreeReset: () => resets++,
|
||||||
autoAccept: () => true,
|
worktreeRequestTimeoutMs: 1,
|
||||||
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 event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(worktreeCreates).toBe(1)
|
||||||
|
expect(createdSessions).toEqual([])
|
||||||
|
expect(selected).toBe("create")
|
||||||
|
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||||
|
expect(resets).toBe(0)
|
||||||
|
|
||||||
|
worktreeHung = false
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(worktreeCreates).toBe(2)
|
||||||
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
|
expect(resets).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps auto-accept bound to the submission server", async () => {
|
test("keeps async submission effects bound to the initiating context", async () => {
|
||||||
|
search = { draftId: "draft-1" }
|
||||||
|
draftServers["draft-1"] = "project-server-a"
|
||||||
|
draftServers["draft-2"] = "project-server-b"
|
||||||
let release = () => {}
|
let release = () => {}
|
||||||
createSessionGate = new Promise<void>((resolve) => {
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
release = resolve
|
release = resolve
|
||||||
})
|
})
|
||||||
const submit = createPromptSubmit({
|
let submitted = 0
|
||||||
prompt,
|
const submit = makeSubmit({
|
||||||
info: () => undefined,
|
onSubmit: () => submitted++,
|
||||||
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)
|
const result = submit.handleSubmit(event)
|
||||||
permissionServer = "server-b"
|
activeSDK = "server-b"
|
||||||
|
activeServerSync = "server-b"
|
||||||
|
activeDirectorySync = "server-b"
|
||||||
|
search.draftId = "draft-2"
|
||||||
release()
|
release()
|
||||||
await result
|
await result
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||||
})
|
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||||
|
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
expect(optimisticServers).toEqual(["server-a"])
|
||||||
search = { draftId: "draft-1" }
|
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||||
const submit = createPromptSubmit({
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||||
prompt,
|
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||||
info: () => undefined,
|
expect(submitted).toBe(0)
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
|
||||||
|
|
||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes the selected variant on optimistic prompts", async () => {
|
test("includes the selected variant on optimistic prompts", async () => {
|
||||||
params = { id: "session-1" }
|
params.id = "session-1"
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
@@ -500,24 +477,12 @@ describe("prompt submit worktree selection", () => {
|
|||||||
commands.push({ name: "review" })
|
commands.push({ name: "review" })
|
||||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(sentCommands).toEqual([
|
expect(sentCommands).toEqual([
|
||||||
{
|
{
|
||||||
@@ -533,66 +498,43 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(serverSessionSyncs).toBe(0)
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("waits for a new workspace before sending an initial shell", async () => {
|
||||||
params = { id: "session-1" }
|
selected = "create"
|
||||||
const model = {
|
const submit = makeSubmit({
|
||||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
mode: () => "shell",
|
||||||
variant: { current: () => "draft-variant" },
|
|
||||||
} as unknown as ModelSelection
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
model,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
|
||||||
|
|
||||||
expect(optimistic[0]).toMatchObject({
|
|
||||||
message: {
|
|
||||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
expect(sentShell).toEqual([])
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
await settle()
|
||||||
expect(optimisticSeeded).toEqual([true])
|
|
||||||
|
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||||
|
expect(sentShell[0]).toMatchObject({
|
||||||
|
sessionID: "session-1",
|
||||||
|
command: "ls",
|
||||||
|
agent: "agent",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
})
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
|
||||||
|
selected = "create"
|
||||||
|
const submit = makeSubmit()
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||||
|
params = { id: "session-1" }
|
||||||
|
|
||||||
|
await submit.abort()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(sentPrompts).toEqual([])
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
|||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
@@ -27,9 +29,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
|||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
cleanup: VoidFunction
|
cleanup: VoidFunction
|
||||||
|
scope: ServerScope
|
||||||
|
sessionID: string
|
||||||
|
serverSync: ServerSync
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = new Map<string, PendingPrompt>()
|
const pending = new Map<string, PendingPrompt>()
|
||||||
|
const submitting = new Set<string>()
|
||||||
|
|
||||||
export type FollowupDraft = {
|
export type FollowupDraft = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -43,6 +49,7 @@ export type FollowupDraft = {
|
|||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["api"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
|
scope: ServerScope
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
@@ -56,6 +63,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
|||||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||||
|
|
||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
|
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||||
|
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
@@ -229,6 +238,7 @@ type PromptSubmitInput = {
|
|||||||
onAbort?: () => void
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
model?: ModelSelection
|
model?: ModelSelection
|
||||||
|
worktreeRequestTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
@@ -244,7 +254,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||||
|
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
@@ -257,18 +268,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const routeSessionID = params.id
|
||||||
|
const owned =
|
||||||
|
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||||
|
? pending.get(pendingSubmission.key)
|
||||||
|
: undefined
|
||||||
|
const sessionID = routeSessionID ?? owned?.sessionID
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||||
serverSync().session.set("todo", sessionID, [])
|
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const key = pendingKey(sessionID)
|
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||||
const queued = pending.get(key)
|
const queued = owned ?? pending.get(key)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
|
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
@@ -300,9 +316,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (target: ServerSync, dir: string, info: Session) => {
|
||||||
serverSync().session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: Session[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -334,6 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (input.working()) void abort()
|
if (input.working()) void abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||||
|
|
||||||
const modelSelection = input.model ?? local.model
|
const modelSelection = input.model ?? local.model
|
||||||
const currentModel = modelSelection.current()
|
const currentModel = modelSelection.current()
|
||||||
@@ -347,295 +364,377 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
const submissionSDK = sdk()
|
||||||
input.resetHistoryNavigation()
|
const submissionSync = sync()
|
||||||
|
const submissionServerSync = serverSync()
|
||||||
const projectDirectory = sdk().directory
|
const submissionScope = submissionSDK.scope
|
||||||
|
const projectDirectory = submissionSDK.directory
|
||||||
|
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||||
|
const sessionID = params.id
|
||||||
|
const isNewSession = !sessionID
|
||||||
|
const currentSession = input.info()
|
||||||
|
const draftID = search.draftId
|
||||||
|
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||||
|
const capturePrompt = prompt.capture
|
||||||
|
const localSession = local.session
|
||||||
|
const handoff = layout.handoff
|
||||||
|
const resetWorktree = input.onNewSessionWorktreeReset
|
||||||
|
const onSubmit = input.onSubmit
|
||||||
const permissionState = permission.currentServerState()
|
const permissionState = permission.currentServerState()
|
||||||
const isNewSession = !params.id
|
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
const submissionKey = ScopedKey.from(
|
||||||
|
submissionScope,
|
||||||
|
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||||
|
)
|
||||||
|
if (submitting.has(submissionKey)) return
|
||||||
|
submitting.add(submissionKey)
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
try {
|
||||||
let client = sdk().client
|
input.addToHistory(currentPrompt, mode)
|
||||||
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
if (isNewSession) {
|
let sessionDirectory = projectDirectory
|
||||||
if (worktreeSelection === "create") {
|
let client = submissionSDK.client
|
||||||
const createdWorktree = await client.worktree
|
let api = submissionSDK.api.session
|
||||||
.create({ directory: projectDirectory })
|
|
||||||
.then((x) => x.data)
|
if (isNewSession) {
|
||||||
.catch((err) => {
|
if (worktreeSelection === "create") {
|
||||||
|
const createdWorktree = await workspaceRequestWithTimeout(
|
||||||
|
(signal) => client.worktree.create({ directory: projectDirectory }, { signal }),
|
||||||
|
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createdWorktree?.directory) {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
description: language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WorktreeState.pending(submissionScope, createdWorktree.directory)
|
||||||
|
sessionDirectory = createdWorktree.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||||
|
sessionDirectory = worktreeSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionDirectory !== projectDirectory) {
|
||||||
|
client = submissionSDK.createClient({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
api = submissionSDK.createApi(sessionDirectory).session
|
||||||
|
submissionServerSync.child(sessionDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = currentSession
|
||||||
|
if (!session && isNewSession) {
|
||||||
|
const created = await submissionSDK.api.session
|
||||||
|
.create({
|
||||||
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
|
})
|
||||||
|
.then(normalizeSessionInfo)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
if (created) {
|
||||||
if (!createdWorktree?.directory) {
|
seed(submissionServerSync, sessionDirectory, created)
|
||||||
showToast({
|
session = created
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
await startTransition(() => {
|
||||||
description: language.t("common.requestFailed"),
|
if (!session) return
|
||||||
|
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||||
|
if (!draftID) resetWorktree?.()
|
||||||
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
|
localSession.promote(sessionDirectory, session.id, {
|
||||||
|
agent: currentAgent.name,
|
||||||
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
|
variant: variant ?? null,
|
||||||
|
})
|
||||||
|
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
|
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||||
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
|
submission.retarget(
|
||||||
|
capturePrompt(
|
||||||
|
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||||
|
{ server: draftServer, scope: submissionScope },
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
}
|
||||||
sessionDirectory = createdWorktree.directory
|
if (!session) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
const model = {
|
||||||
sessionDirectory = worktreeSelection
|
modelID: currentModel.id,
|
||||||
|
providerID: currentModel.provider.id,
|
||||||
|
}
|
||||||
|
const agent = currentAgent.name
|
||||||
|
const draft: FollowupDraft = {
|
||||||
|
sessionID: session.id,
|
||||||
|
sessionDirectory,
|
||||||
|
prompt: currentPrompt,
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
const clearInput = () => {
|
||||||
client = sdk().createClient({
|
submission.clear()
|
||||||
directory: sessionDirectory,
|
input.setMode("normal")
|
||||||
throwOnError: true,
|
input.setPopover(null)
|
||||||
})
|
|
||||||
serverSync().child(sessionDirectory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onNewSessionWorktreeReset?.()
|
const restoreInput = () => {
|
||||||
}
|
const restored = submission.restore()
|
||||||
|
if (!restored) return false
|
||||||
let session = input.info()
|
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||||
if (!session && isNewSession) {
|
if (!submission.current(prompt.capture())) return true
|
||||||
const created = await sdk()
|
input.setMode(mode)
|
||||||
.api.session.create({
|
input.setPopover(null)
|
||||||
agent: currentAgent.name,
|
requestAnimationFrame(() => {
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
const editor = input.editor()
|
||||||
location: { directory: sessionDirectory },
|
if (!editor) return
|
||||||
})
|
editor.focus()
|
||||||
.then(normalizeSessionInfo)
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
.catch((err) => {
|
input.queueScroll()
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (created) {
|
|
||||||
seed(sessionDirectory, created)
|
|
||||||
session = created
|
|
||||||
await startTransition(() => {
|
|
||||||
if (!session) return
|
|
||||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
|
||||||
agent: currentAgent.name,
|
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
|
||||||
variant: variant ?? null,
|
|
||||||
})
|
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
|
||||||
const draftID = search.draftId
|
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
|
||||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!session) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
||||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = {
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
modelID: currentModel.id,
|
input.onQueue?.(draft)
|
||||||
providerID: currentModel.provider.id,
|
clearContext(submission.target())
|
||||||
}
|
clearInput()
|
||||||
const agent = currentAgent.name
|
return
|
||||||
const draft: FollowupDraft = {
|
}
|
||||||
sessionID: session.id,
|
|
||||||
sessionDirectory,
|
|
||||||
prompt: currentPrompt,
|
|
||||||
context,
|
|
||||||
agent,
|
|
||||||
model,
|
|
||||||
variant,
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearInput = () => {
|
const startWorkspaceOperation = (messageID: string) => {
|
||||||
submission.clear()
|
if (!isNewSession) return
|
||||||
input.setMode("normal")
|
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||||
input.setPopover(null)
|
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||||
}
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
}
|
||||||
|
if (worktreeSelection !== "create") return
|
||||||
|
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||||
|
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||||
|
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
}
|
||||||
|
|
||||||
const restoreInput = () => {
|
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||||
const restored = submission.restore()
|
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||||
if (!restored) return false
|
if (!worktree) return true
|
||||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
if (worktree.status === "ready") {
|
||||||
if (!submission.current(prompt.capture())) return true
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
input.setMode(mode)
|
return true
|
||||||
input.setPopover(null)
|
}
|
||||||
requestAnimationFrame(() => {
|
if (worktree.status === "failed") {
|
||||||
const editor = input.editor()
|
WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
if (!editor) return
|
throw new Error(worktree.message)
|
||||||
editor.focus()
|
}
|
||||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
|
||||||
input.queueScroll()
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
input.onQueue?.(draft)
|
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||||
clearContext(submission.target())
|
}
|
||||||
clearInput()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.onSubmit?.()
|
const controller = new AbortController()
|
||||||
|
const key = pendingKey(submissionScope, session.id)
|
||||||
if (mode === "shell") {
|
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||||
clearInput()
|
pending.set(key, {
|
||||||
const eventID = Event.ID.create()
|
abort: controller,
|
||||||
sdk()
|
cleanup,
|
||||||
.api.session.shell({
|
scope: submissionScope,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: eventID,
|
serverSync: submissionServerSync,
|
||||||
command: text,
|
|
||||||
agent,
|
|
||||||
model,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
if (controller.signal.aborted) {
|
||||||
description: errorMessage(err),
|
resolve({ status: "failed", message: "aborted" })
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
controller.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
resolve({ status: "failed", message: "aborted" })
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutMs = 5 * 60 * 1000
|
||||||
|
const timer = { id: undefined as number | undefined }
|
||||||
|
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
|
timer.id = window.setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
status: "failed",
|
||||||
|
message: language.t("workspace.error.stillPreparing"),
|
||||||
|
})
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||||
|
abortWait,
|
||||||
|
timeout,
|
||||||
|
]).finally(() => {
|
||||||
|
pending.delete(key)
|
||||||
|
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||||
|
if (timer.id === undefined) return
|
||||||
|
clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
if (controller.signal.aborted) return false
|
||||||
|
if (result.status === "failed") {
|
||||||
|
WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
throw new Error(result.message)
|
||||||
|
}
|
||||||
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
const eventID = Event.ID.create()
|
||||||
|
startWorkspaceOperation(eventID)
|
||||||
|
void waitForWorktree(() => {
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
.then((ready) => {
|
||||||
}
|
if (!ready) return
|
||||||
|
return api.shell({
|
||||||
if (text.startsWith("/")) {
|
sessionID: session.id,
|
||||||
const [cmdName, ...args] = text.split(" ")
|
id: eventID,
|
||||||
const commandName = cmdName.slice(1)
|
command: text,
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
agent,
|
||||||
if (customCommand) {
|
model,
|
||||||
clearInput()
|
})
|
||||||
const messageID = Identifier.ascending("message")
|
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
|
||||||
sdk()
|
|
||||||
.api.session.command({
|
|
||||||
sessionID: session.id,
|
|
||||||
id: messageID,
|
|
||||||
command: commandName,
|
|
||||||
arguments: args.join(" "),
|
|
||||||
agent,
|
|
||||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
|
||||||
files: await Promise.all(
|
|
||||||
images.map(async (attachment) => ({
|
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
|
||||||
name: attachment.filename,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
if (text.startsWith("/")) {
|
||||||
const messageID = Identifier.ascending("message")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
const removeOptimisticMessage = () => {
|
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||||
sync().session.optimistic.remove({
|
if (customCommand) {
|
||||||
directory: sessionDirectory,
|
clearInput()
|
||||||
sessionID: session.id,
|
const messageID = Identifier.ascending("message")
|
||||||
messageID,
|
startWorkspaceOperation(messageID)
|
||||||
})
|
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||||
}
|
void waitForWorktree(() => {
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
restoreInput()
|
||||||
clearInput()
|
})
|
||||||
|
.then(async (ready) => {
|
||||||
const waitForWorktree = async () => {
|
if (!ready) return
|
||||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
return api.command({
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
if (sessionDirectory === projectDirectory) {
|
command: commandName,
|
||||||
sync().set("session_status", session.id, { type: "busy" })
|
arguments: args.join(" "),
|
||||||
|
agent,
|
||||||
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
|
files: await Promise.all(
|
||||||
|
images.map(async (attachment) => ({
|
||||||
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
|
name: attachment.filename,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
|
const messageID = Identifier.ascending("message")
|
||||||
|
startWorkspaceOperation(messageID)
|
||||||
|
|
||||||
|
const removeOptimisticMessage = () => {
|
||||||
|
submissionSync.session.optimistic.remove({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
sessionID: session.id,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
|
clearInput()
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
void sendFollowupDraft({
|
||||||
|
api,
|
||||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
scope: submissionScope,
|
||||||
if (controller.signal.aborted) {
|
sync: submissionSync,
|
||||||
resolve({ status: "failed", message: "aborted" })
|
serverSync: submissionServerSync,
|
||||||
return
|
draft,
|
||||||
|
messageID,
|
||||||
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
before: () => waitForWorktree(cleanup),
|
||||||
|
}).catch((err) => {
|
||||||
|
pending.delete(pendingKey(submissionScope, session.id))
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
controller.signal.addEventListener(
|
showToast({
|
||||||
"abort",
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
() => {
|
description: errorMessage(err),
|
||||||
resolve({ status: "failed", message: "aborted" })
|
})
|
||||||
},
|
removeOptimisticMessage()
|
||||||
{ once: true },
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
const timeoutMs = 5 * 60 * 1000
|
submitting.delete(submissionKey)
|
||||||
const timer = { id: undefined as number | undefined }
|
|
||||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
||||||
timer.id = window.setTimeout(() => {
|
|
||||||
resolve({
|
|
||||||
status: "failed",
|
|
||||||
message: language.t("workspace.error.stillPreparing"),
|
|
||||||
})
|
|
||||||
}, timeoutMs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await Promise.race([
|
|
||||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
|
||||||
abortWait,
|
|
||||||
timeout,
|
|
||||||
]).finally(() => {
|
|
||||||
if (timer.id === undefined) return
|
|
||||||
clearTimeout(timer.id)
|
|
||||||
})
|
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (controller.signal.aborted) return false
|
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
|
||||||
api: sdk().api.session,
|
|
||||||
sync: sync(),
|
|
||||||
serverSync: serverSync(),
|
|
||||||
draft,
|
|
||||||
messageID,
|
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
|
||||||
before: waitForWorktree,
|
|
||||||
}).catch((err) => {
|
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
|
||||||
}
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
removeOptimisticMessage()
|
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
projectRoot: string
|
projectRoot: string
|
||||||
workspaces: string[]
|
workspaces: string[]
|
||||||
branch?: string
|
branch?: string
|
||||||
|
onboarding?: boolean
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
|
onViewAll: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
let pending: string | undefined
|
const [search, setSearch] = createSignal("")
|
||||||
|
let searchInput: HTMLInputElement | undefined
|
||||||
|
let focusSearch = false
|
||||||
|
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||||
|
const workspaces = createMemo(() => {
|
||||||
|
const query = search().trim().toLowerCase()
|
||||||
|
if (!query) return props.workspaces
|
||||||
|
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||||
|
})
|
||||||
const icon = () => {
|
const icon = () => {
|
||||||
if (selected() === "main") return "monitor"
|
if (selected() === "main") return "monitor"
|
||||||
if (selected() === "create") return "workspace-new"
|
if (selected() === "create") return "workspace-new"
|
||||||
return "workspace"
|
return "workspace-isolated"
|
||||||
}
|
}
|
||||||
const select = (value: string) => {
|
const select = (value: string) => {
|
||||||
pending = value
|
pending = { type: "select", value }
|
||||||
}
|
}
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) return
|
if (open) {
|
||||||
const value = pending
|
setSearch("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const action = pending
|
||||||
pending = undefined
|
pending = undefined
|
||||||
if (value) props.onChange(value)
|
if (action?.type === "select") props.onChange(action.value)
|
||||||
|
if (action?.type === "viewAll") {
|
||||||
|
props.onViewAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
props.onDone()
|
props.onDone()
|
||||||
}
|
}
|
||||||
const label = () => {
|
const label = () => {
|
||||||
@@ -41,87 +57,217 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
<TooltipV2
|
||||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
placement="top"
|
||||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
value={
|
||||||
<span class="min-w-0 truncate">{label()}</span>
|
props.onboarding ? (
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
<div class="flex flex-col gap-1 text-left">
|
||||||
</MenuV2.Trigger>
|
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||||
<MenuV2.Portal>
|
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||||
<MenuV2.Content class="w-[180px]">
|
<span>{language.t("workspace.onboarding.title")}</span>
|
||||||
<MenuV2.Group>
|
</div>
|
||||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
<MenuV2.Item onSelect={() => select("main")}>
|
{language.t("workspace.onboarding.description")}
|
||||||
<IconV2 name="monitor" />
|
</span>
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
</div>
|
||||||
<Show when={selected() === "main"}>
|
) : (
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
language.t("session.new.workspace.trigger.tooltip")
|
||||||
</Show>
|
)
|
||||||
</MenuV2.Item>
|
}
|
||||||
<MenuV2.Item onSelect={() => select("create")}>
|
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||||
<IconV2 name="workspace-new" />
|
class="min-w-0"
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
>
|
||||||
<Show when={selected() === "create"}>
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
<MenuV2.Trigger
|
||||||
</Show>
|
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||||
</MenuV2.Item>
|
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||||
</MenuV2.Group>
|
>
|
||||||
<Show when={props.workspaces.length > 0}>
|
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<MenuV2.Separator />
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
<Show when={props.onboarding}>
|
||||||
<MenuV2.SubTrigger>
|
<span
|
||||||
<IconV2 name="workspace" />
|
data-slot="workspace-onboarding-dot"
|
||||||
{language.t("session.new.workspace.existing")}
|
aria-hidden="true"
|
||||||
</MenuV2.SubTrigger>
|
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||||
<MenuV2.Portal>
|
/>
|
||||||
<MenuV2.SubContent class="max-w-[200px]">
|
|
||||||
<For each={props.workspaces}>
|
|
||||||
{(workspace) => (
|
|
||||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
|
||||||
<IconV2 name="workspace-isolated" />
|
|
||||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
|
||||||
<Show when={selected() === workspace}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</MenuV2.SubContent>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2.Sub>
|
|
||||||
</Show>
|
</Show>
|
||||||
</MenuV2.Content>
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
</MenuV2.Portal>
|
</MenuV2.Trigger>
|
||||||
</MenuV2>
|
<MenuV2.Portal>
|
||||||
<PromptGitStatus branch={props.branch} />
|
<MenuV2.Content class="w-[200px]">
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
|
<Icon name="monitor" />
|
||||||
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
value={
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span>{language.t("session.new.workspace.local")}</span>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("session.new.workspace.local.tooltip")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||||
|
</TooltipV2>
|
||||||
|
<Show when={selected() === "main"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
|
<Icon name="workspace-new" />
|
||||||
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
value={
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span>{language.t("workspace.new")}</span>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("session.new.workspace.new.tooltip")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||||
|
</TooltipV2>
|
||||||
|
<Show when={selected() === "create"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<Show
|
||||||
|
when={props.workspaces.length > 0}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Sub
|
||||||
|
gutter={0}
|
||||||
|
overlap
|
||||||
|
overflowPadding={8}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
focusSearch = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!focusSearch || props.workspaces.length < 10) return
|
||||||
|
focusSearch = false
|
||||||
|
requestAnimationFrame(() => searchInput?.focus())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuV2.SubTrigger
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "ArrowRight" ||
|
||||||
|
event.key === "ArrowLeft" ||
|
||||||
|
event.key === "Enter" ||
|
||||||
|
event.key === " "
|
||||||
|
)
|
||||||
|
focusSearch = true
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||||
|
</span>
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
|
<Show when={props.workspaces.length >= 10}>
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||||
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
searchInput = element
|
||||||
|
}}
|
||||||
|
value={search()}
|
||||||
|
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||||
|
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||||
|
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||||
|
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "Escape" ||
|
||||||
|
event.key === "ArrowDown" ||
|
||||||
|
event.key === "ArrowUp" ||
|
||||||
|
event.key === "Enter"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
<Show when={selected() === workspace}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</TooltipV2>
|
||||||
|
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const label = () => {
|
const label = () => {
|
||||||
if (props.noGit) return language.t("session.new.git.none")
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
|
if (!props.branch) return undefined
|
||||||
|
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||||
return props.branch
|
return props.branch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const icon = () => {
|
||||||
|
if (props.noGit) return "monitor"
|
||||||
|
if (props.from) return "branch-out"
|
||||||
|
return "branch"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={label()}>
|
<Show when={label()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<>
|
<TooltipV2
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
placement="top"
|
||||||
<TooltipV2
|
value={value()}
|
||||||
placement="top"
|
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||||
value={value()}
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
class="min-w-0 max-w-[220px]"
|
>
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||||
>
|
<Icon
|
||||||
<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]">
|
name={icon()}
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
size="small"
|
||||||
<span class="min-w-0 truncate">{value()}</span>
|
class="shrink-0 text-v2-icon-icon-muted"
|
||||||
</div>
|
/>
|
||||||
</TooltipV2>
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
</>
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,11 +78,10 @@ export const ServerRowMenuView: Component<{
|
|||||||
>
|
>
|
||||||
{props.labels.edit}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<Show when={props.canDefault && !props.isDefault}>
|
<Show when={props.canDefault}>
|
||||||
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
<MenuV2.Item disabled={props.isDefault} onSelect={props.onSetDefault}>
|
||||||
</Show>
|
{props.labels.default}
|
||||||
<Show when={props.canDefault && props.isDefault}>
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { workspaceDirectories } from "@/utils/workspace"
|
||||||
|
import {
|
||||||
|
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
workspaceRequestWithTimeout,
|
||||||
|
} from "@/utils/workspace-request"
|
||||||
|
|
||||||
|
export function SessionWorkspaceMenu(props: {
|
||||||
|
eligible?: boolean
|
||||||
|
sessionID: string
|
||||||
|
project: Project
|
||||||
|
directory: string
|
||||||
|
messageID?: string
|
||||||
|
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||||
|
gutter?: number
|
||||||
|
class?: string
|
||||||
|
contentClass?: string
|
||||||
|
children: JSX.Element
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const openWorkspaces = useSettingsDialog("workspaces")
|
||||||
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
|
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||||
|
const blocked = () =>
|
||||||
|
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||||
|
const workspaces = () =>
|
||||||
|
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||||
|
|
||||||
|
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||||
|
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||||
|
WorkspaceOperation.fail(scope, sessionID)
|
||||||
|
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||||
|
}
|
||||||
|
const move = async (selection: "create" | string) => {
|
||||||
|
if (store.selected || blocked()) return
|
||||||
|
const sdk = serverSDK()
|
||||||
|
const sync = serverSync()
|
||||||
|
const scope = sdk.scope
|
||||||
|
const sessionID = props.sessionID
|
||||||
|
const messageID = props.messageID
|
||||||
|
const root = props.project.worktree
|
||||||
|
const source = props.directory
|
||||||
|
setStore("selected", selection)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination =
|
||||||
|
selection === "create"
|
||||||
|
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
|
||||||
|
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
stillPreparing: language.t("workspace.error.stillPreparing"),
|
||||||
|
})
|
||||||
|
: selection
|
||||||
|
if (!destination) return
|
||||||
|
|
||||||
|
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||||
|
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||||
|
await workspaceRequestWithTimeout(
|
||||||
|
(signal) =>
|
||||||
|
sdk.client.experimental.controlPlane.moveSession(
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
destination: { directory: destination },
|
||||||
|
moveChanges: true,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
language.t("workspace.move.failed"),
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
const session = await workspaceRequestWithTimeout(
|
||||||
|
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||||
|
language.t("workspace.move.failed"),
|
||||||
|
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
if (!session || pathKey(session.directory) !== pathKey(destination))
|
||||||
|
throw new Error(language.t("workspace.move.failed"))
|
||||||
|
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||||
|
sync.reindexSession(sessionID, source)
|
||||||
|
} catch (error) {
|
||||||
|
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||||
|
} finally {
|
||||||
|
setStore("selected", undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuV2
|
||||||
|
placement={props.placement ?? "bottom-end"}
|
||||||
|
gutter={props.gutter ?? 4}
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||||
|
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||||
|
<Icon name="monitor" />
|
||||||
|
{language.t("session.new.workspace.local")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||||
|
<Icon name="workspace-new" />
|
||||||
|
{language.t("workspace.new")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<Show when={workspaces().length > 0}>
|
||||||
|
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||||
|
<MenuV2.SubTrigger>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||||
|
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(
|
||||||
|
root: string,
|
||||||
|
sessionID: string,
|
||||||
|
messageID: string | undefined,
|
||||||
|
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||||
|
fail: (message: string) => void,
|
||||||
|
messages: { createFailed: string; stillPreparing: string },
|
||||||
|
) {
|
||||||
|
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
|
||||||
|
const created = await workspaceRequestWithTimeout(
|
||||||
|
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
|
||||||
|
messages.createFailed,
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch((error) => {
|
||||||
|
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!created?.directory) return
|
||||||
|
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||||
|
Worktree.pending(serverSDK.scope, created.directory)
|
||||||
|
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
|
||||||
|
timer.id = setTimeout(
|
||||||
|
() => resolve({ status: "failed", message: messages.stillPreparing }),
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
|
||||||
|
if (timer.id) clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
if (!ready || ready.status === "failed") {
|
||||||
|
fail(ready?.message ?? messages.createFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return created.directory
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
|||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -220,6 +223,31 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
] satisfies { label: string; value: () => JSX.Element }[]
|
] satisfies { label: string; value: () => JSX.Element }[]
|
||||||
|
|
||||||
|
const exportSession = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
try {
|
||||||
|
const data = await fetchSessionExport({
|
||||||
|
sessionID,
|
||||||
|
client: sdk().client,
|
||||||
|
})
|
||||||
|
const filename = sessionExportFilename(data.info)
|
||||||
|
downloadSessionExport(filename, data)
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("toast.session.export.success.title"),
|
||||||
|
description: language.t("toast.session.export.success.description", { filename }),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.session.export.failed.title"),
|
||||||
|
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
@@ -328,7 +356,18 @@ export function SessionContextTab() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
|
||||||
|
onClick={exportSession}
|
||||||
|
>
|
||||||
|
<Icon name="download" size="small" />
|
||||||
|
<span>{language.t("context.export.session")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
@@ -43,6 +44,7 @@ export const SettingsModels: Component = () => {
|
|||||||
const SettingsModelsContent: Component = () => {
|
const SettingsModelsContent: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
useServerSync()().loadProviders()
|
||||||
|
|
||||||
const list = useFilteredList<ModelItem>({
|
const list = useFilteredList<ModelItem>({
|
||||||
items: (_filter) => models.list(),
|
items: (_filter) => models.list(),
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Component, Show, createMemo, createResource } from "solid-js"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { ExternalLink } from "../external-link"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import {
|
||||||
|
createAppearanceSettingsController,
|
||||||
|
type AppearanceSettingsController,
|
||||||
|
} from "./general-controllers"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||||
|
const fontSettings = {
|
||||||
|
ui: {
|
||||||
|
action: "settings-ui-font",
|
||||||
|
title: "settings.general.row.uiFont.title",
|
||||||
|
description: "settings.general.row.uiFont.description",
|
||||||
|
font: "ui",
|
||||||
|
input: "setUI",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
action: "settings-code-font",
|
||||||
|
title: "settings.general.row.font.title",
|
||||||
|
description: "settings.general.row.font.description",
|
||||||
|
font: "code",
|
||||||
|
input: "setCode",
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
action: "settings-terminal-font",
|
||||||
|
title: "settings.general.row.terminalFont.title",
|
||||||
|
description: "settings.general.row.terminalFont.description",
|
||||||
|
font: "terminal",
|
||||||
|
input: "setTerminal",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const FontSetting: Component<{
|
||||||
|
kind: "ui" | "code" | "terminal"
|
||||||
|
fonts: AppearanceSettingsController["fonts"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => fontSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action={config().action}
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={props.fonts[config().font]().value}
|
||||||
|
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||||
|
placeholder={props.fonts[config().font]().placeholder}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t(config().title)}
|
||||||
|
style={{ "font-family": props.fonts[config().font]().family }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsAppearanceV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const appearance = createAppearanceSettingsController()
|
||||||
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
|
() => desktop() && "getPinchZoomEnabled" in platform,
|
||||||
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
|
{ initialValue: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
|
setPinchZoom(checked)
|
||||||
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
|
if (!update) return
|
||||||
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreDefaults = () => {
|
||||||
|
appearance.scheme.select("system")
|
||||||
|
appearance.fonts.setUI("")
|
||||||
|
appearance.fonts.setCode("")
|
||||||
|
appearance.fonts.setTerminal("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={restoreDefaults}>
|
||||||
|
{language.t("common.reset")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.colorScheme.title")}
|
||||||
|
description={language.t("settings.general.row.colorScheme.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-color-scheme"
|
||||||
|
options={schemeOptions}
|
||||||
|
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
label={(option) => {
|
||||||
|
if (option === "system") return language.t("theme.scheme.system")
|
||||||
|
if (option === "light") return language.t("theme.scheme.light")
|
||||||
|
return language.t("theme.scheme.dark")
|
||||||
|
}}
|
||||||
|
onSelect={(option) => option && appearance.scheme.select(option)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.theme.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||||
|
{language.t("common.learnMore")}
|
||||||
|
</ExternalLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-theme"
|
||||||
|
options={appearance.theme.options()}
|
||||||
|
current={appearance.theme.current()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.name}
|
||||||
|
onSelect={appearance.theme.select}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<FontSetting kind="ui" fonts={appearance.fonts} />
|
||||||
|
<FontSetting kind="code" fonts={appearance.fonts} />
|
||||||
|
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={desktop()}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.pinchZoom.title")}
|
||||||
|
description={language.t("settings.general.row.pinchZoom.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-pinch-zoom">
|
||||||
|
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,15 +5,19 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SettingsGeneralV2 } from "./general"
|
import { SettingsGeneralV2 } from "./general"
|
||||||
|
import { SettingsAppearanceV2 } from "./appearance"
|
||||||
import { SettingsKeybinds } from "../settings-keybinds"
|
import { SettingsKeybinds } from "../settings-keybinds"
|
||||||
|
import { SettingsNotificationsV2 } from "./notifications"
|
||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
import "./settings-v2.css"
|
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||||
|
import { SettingsProjectsV2 } from "./projects"
|
||||||
|
import { SettingsExtensionsV2 } from "./extensions"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -22,18 +26,16 @@ export const DialogSettings: Component<{
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const layout = useLayout()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
const route = layout.route()
|
const active = tabs.active()
|
||||||
if (route.type === "dir-new-sesssion") return route.dir
|
if (active?.type === "draft") return active.directory
|
||||||
if (route.type === "draft") {
|
if (active?.type === "session") {
|
||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
const session = serverSync().session.get(active.sessionId)
|
||||||
return draft?.type === "draft" ? draft.directory : undefined
|
if (session) return session.directory
|
||||||
}
|
}
|
||||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
|
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,62 +54,94 @@ export const DialogSettings: Component<{
|
|||||||
>
|
>
|
||||||
<TabsV2.List>
|
<TabsV2.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-3 w-full">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<div class="flex flex-col gap-1.5">
|
<TabsV2.Trigger value="general">
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
<Icon name="sliders" />
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
{language.t("settings.tab.preferences")}
|
||||||
<TabsV2.Trigger value="general">
|
</TabsV2.Trigger>
|
||||||
<Icon name="sliders" />
|
<TabsV2.Trigger value="appearance">
|
||||||
{language.t("settings.tab.general")}
|
<Icon name="appearance" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.general.section.appearance")}
|
||||||
<TabsV2.Trigger value="shortcuts">
|
</TabsV2.Trigger>
|
||||||
<Icon name="keyboard" />
|
<TabsV2.Trigger value="notifications">
|
||||||
{language.t("settings.tab.shortcuts")}
|
<Icon name="notifications" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.general.section.notifications")}
|
||||||
</div>
|
</TabsV2.Trigger>
|
||||||
</div>
|
<TabsV2.Trigger value="shortcuts">
|
||||||
|
<Icon name="keyboard" />
|
||||||
|
{language.t("settings.tab.shortcuts")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
<TabsV2.Trigger value="workspaces">
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
<Icon name="workspace-isolated" />
|
||||||
<TabsV2.Trigger value="servers">
|
{language.t("settings.tab.workspaces")}
|
||||||
<Icon name="server" />
|
</TabsV2.Trigger>
|
||||||
{language.t("status.popover.tab.servers")}
|
<TabsV2.Trigger value="servers">
|
||||||
</TabsV2.Trigger>
|
<Icon name="server" />
|
||||||
<TabsV2.Trigger value="providers">
|
{language.t("status.popover.tab.servers")}
|
||||||
<Icon name="providers" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.providers.title")}
|
<TabsV2.Trigger value="projects">
|
||||||
</TabsV2.Trigger>
|
<Icon name="folder" />
|
||||||
<TabsV2.Trigger value="models">
|
{language.t("settings.tab.projects")}
|
||||||
<Icon name="models" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.models.title")}
|
</div>
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
<div class="flex flex-col gap-1 w-full">
|
||||||
</div>
|
<TabsV2.Trigger value="providers">
|
||||||
|
<Icon name="providers" />
|
||||||
|
{language.t("settings.providers.title")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="models">
|
||||||
|
<Icon name="models" />
|
||||||
|
{language.t("settings.models.title")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="extensions">
|
||||||
|
<Icon name="extensions" />
|
||||||
|
{language.t("settings.tab.extensions")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-nav-footer">
|
<div class="settings-v2-nav-footer">
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
<span>{language.t("app.name.desktop")}</span>
|
||||||
<span>v{platform.version}</span>
|
<span>v{platform.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
||||||
|
<SettingsAppearanceV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
||||||
|
<SettingsNotificationsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||||
|
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="projects" class="settings-v2-panel">
|
||||||
|
<SettingsProjectsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
<SettingsModelsV2 />
|
<SettingsModelsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
||||||
|
<SettingsExtensionsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
</TabsV2>
|
</TabsV2>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ExternalLink } from "../external-link"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
type ExtensionSubTab = "mcps" | "plugins" | "skills"
|
||||||
|
|
||||||
|
interface McpRowItem {
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRowItem {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillRowItem {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsExtensionsV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
|
const [activeSubTab, setActiveSubTab] = createSignal<ExtensionSubTab>("mcps")
|
||||||
|
|
||||||
|
const [mcpOverrides, setMcpOverrides] = createSignal<Record<string, boolean>>({})
|
||||||
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
|
const configMcp = (serverSync().data.config as { mcp?: Record<string, { disabled?: boolean }> })?.mcp ?? {}
|
||||||
|
const configEntries = Object.entries(configMcp).map(([name, conf]) => ({
|
||||||
|
name,
|
||||||
|
status: conf.disabled ? "disabled" : "connected",
|
||||||
|
}))
|
||||||
|
|
||||||
|
const firstProject = server.projects.list()[0]?.worktree
|
||||||
|
const childMcp = firstProject ? serverSync().child(firstProject, { mcp: true })[0].mcp : {}
|
||||||
|
const childEntries = Object.entries(childMcp ?? {}).map(([name, stat]) => ({
|
||||||
|
name,
|
||||||
|
status: stat?.status,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const map = new Map<string, McpRowItem>()
|
||||||
|
for (const item of configEntries) map.set(item.name, item)
|
||||||
|
for (const item of childEntries) {
|
||||||
|
const existing = map.get(item.name)
|
||||||
|
map.set(item.name, { ...existing, ...item })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMcpEnabled = (item: McpRowItem) => {
|
||||||
|
if (mcpOverrides()[item.name] !== undefined) return mcpOverrides()[item.name]
|
||||||
|
return item.status === "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||||
|
setMcpOverrides((prev) => ({ ...prev, [item.name]: checked }))
|
||||||
|
const firstProject = server.projects.list()[0]?.worktree
|
||||||
|
if (firstProject) {
|
||||||
|
void serverSync().mcp.toggle(firstProject, item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||||
|
const raw = serverSync().data.config.plugin ?? []
|
||||||
|
return raw.map((item) => {
|
||||||
|
const name = typeof item === "string" ? item : item[0]
|
||||||
|
return { name }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const skills = createMemo<SkillRowItem[]>(() => {
|
||||||
|
const configSkills = (serverSync().data.config as { skills?: string[] })?.skills ?? []
|
||||||
|
return configSkills.map((name) => ({ name }))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.mcps.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect value={selectedServer()} onChange={setSelectedServer} includeAll />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
{/* Sub-Tabs Pill Strip */}
|
||||||
|
<div class="settings-v2-extensions-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeSubTab() === "mcps"}
|
||||||
|
data-selected={activeSubTab() === "mcps" ? "" : undefined}
|
||||||
|
onClick={() => setActiveSubTab("mcps")}
|
||||||
|
>
|
||||||
|
{language.t("status.popover.tab.mcp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeSubTab() === "plugins"}
|
||||||
|
data-selected={activeSubTab() === "plugins" ? "" : undefined}
|
||||||
|
onClick={() => setActiveSubTab("plugins")}
|
||||||
|
>
|
||||||
|
{language.t("status.popover.tab.plugins")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeSubTab() === "skills"}
|
||||||
|
data-selected={activeSubTab() === "skills" ? "" : undefined}
|
||||||
|
onClick={() => setActiveSubTab("skills")}
|
||||||
|
>
|
||||||
|
{language.t("settings.permissions.tool.skill.title")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCPs Sub-Tab */}
|
||||||
|
<Show when={activeSubTab() === "mcps"}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={mcps()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isMcpEnabled(item)}
|
||||||
|
onChange={(checked) => handleMcpToggle(item, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Plugins Sub-Tab */}
|
||||||
|
<Show when={activeSubTab() === "plugins"}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={plugins()}>
|
||||||
|
{(plugin) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Skills Sub-Tab */}
|
||||||
|
<Show when={activeSubTab() === "skills"}>
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="text-13-regular text-v2-text-accent hover:underline"
|
||||||
|
href="https://opencode.ai/docs/skills/"
|
||||||
|
>
|
||||||
|
{language.t("settings.extensions.addSkills")}
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={skills()}>
|
||||||
|
{(skill) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,74 +1,25 @@
|
|||||||
import { Component, Show, createMemo, createResource } from "solid-js"
|
import { Component, Show, createMemo } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import { useSettings } from "@/context/settings"
|
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||||
import { ExternalLink } from "../external-link"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
import {
|
import {
|
||||||
createAppearanceSettingsController,
|
|
||||||
createPermissionScopeController,
|
createPermissionScopeController,
|
||||||
createShellOptions,
|
createShellOptions,
|
||||||
createShellSettingsController,
|
createShellSettingsController,
|
||||||
createSoundSettingsController,
|
|
||||||
soundOptions,
|
|
||||||
type AppearanceSettingsController,
|
|
||||||
type PermissionScopeController,
|
type PermissionScopeController,
|
||||||
type ShellSettingsController,
|
type ShellSettingsController,
|
||||||
type SoundSettingsController,
|
|
||||||
} from "./general-controllers"
|
} from "./general-controllers"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
|
||||||
const fontSettings = {
|
|
||||||
ui: {
|
|
||||||
action: "settings-ui-font",
|
|
||||||
title: "settings.general.row.uiFont.title",
|
|
||||||
description: "settings.general.row.uiFont.description",
|
|
||||||
font: "ui",
|
|
||||||
input: "setUI",
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
action: "settings-code-font",
|
|
||||||
title: "settings.general.row.font.title",
|
|
||||||
description: "settings.general.row.font.description",
|
|
||||||
font: "code",
|
|
||||||
input: "setCode",
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
action: "settings-terminal-font",
|
|
||||||
title: "settings.general.row.terminalFont.title",
|
|
||||||
description: "settings.general.row.terminalFont.description",
|
|
||||||
font: "terminal",
|
|
||||||
input: "setTerminal",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
const soundSettings = {
|
|
||||||
agent: {
|
|
||||||
action: "settings-sounds-agent",
|
|
||||||
title: "settings.general.sounds.agent.title",
|
|
||||||
description: "settings.general.sounds.agent.description",
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
action: "settings-sounds-permissions",
|
|
||||||
title: "settings.general.sounds.permissions.title",
|
|
||||||
description: "settings.general.sounds.permissions.description",
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
action: "settings-sounds-errors",
|
|
||||||
title: "settings.general.sounds.errors.title",
|
|
||||||
description: "settings.general.sounds.errors.description",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +38,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WorkspaceDestinationSetting: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||||
|
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||||
|
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||||
|
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.workspaces.default.title")}
|
||||||
|
description={language.t("settings.workspaces.default.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
options={options()}
|
||||||
|
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||||
|
value={(option) => option.value}
|
||||||
|
label={(option) => option.label}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -119,130 +98,6 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={schemeOptions}
|
|
||||||
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
label={(option) => {
|
|
||||||
if (option === "system") return language.t("theme.scheme.system")
|
|
||||||
if (option === "light") return language.t("theme.scheme.light")
|
|
||||||
return language.t("theme.scheme.dark")
|
|
||||||
}}
|
|
||||||
onSelect={(option) => option && props.controller.scheme.select(option)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
|
||||||
{language.t("common.learnMore")}
|
|
||||||
</ExternalLink>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={props.controller.theme.options()}
|
|
||||||
current={props.controller.theme.current()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => option.name}
|
|
||||||
onSelect={props.controller.theme.select}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<FontSetting kind="ui" fonts={props.controller.fonts} />
|
|
||||||
<FontSetting kind="code" fonts={props.controller.fonts} />
|
|
||||||
<FontSetting kind="terminal" fonts={props.controller.fonts} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FontSetting: Component<{
|
|
||||||
kind: "ui" | "code" | "terminal"
|
|
||||||
fonts: AppearanceSettingsController["fonts"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => fontSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action={config().action}
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={props.fonts[config().font]().value}
|
|
||||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
|
||||||
placeholder={props.fonts[config().font]().placeholder}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t(config().title)}
|
|
||||||
style={{ "font-family": props.fonts[config().font]().family }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
return (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SoundSetting kind="agent" channel={props.controller.agent} />
|
|
||||||
<SoundSetting kind="permissions" channel={props.controller.permissions} />
|
|
||||||
<SoundSetting kind="errors" channel={props.controller.errors} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SoundSetting: Component<{
|
|
||||||
kind: "agent" | "permissions" | "errors"
|
|
||||||
channel: SoundSettingsController["agent"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => soundSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action={config().action}
|
|
||||||
options={soundOptions}
|
|
||||||
current={props.channel.current()}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => language.t(option.label)}
|
|
||||||
onHighlight={props.channel.highlight}
|
|
||||||
onSelect={props.channel.select}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LanguageSetting = () => {
|
const LanguageSetting = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -282,21 +137,17 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
const shell = createShellSettingsController()
|
const shell = createShellSettingsController()
|
||||||
const appearance = createAppearanceSettingsController()
|
|
||||||
const sounds = createSoundSettingsController()
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const restoreDefaults = () => {
|
||||||
() => desktop() && "getPinchZoomEnabled" in platform,
|
settings.general.setAutoSave(true)
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
settings.general.setShowReasoningSummaries(true)
|
||||||
{ initialValue: false },
|
settings.general.setShellToolPartsExpanded(false)
|
||||||
)
|
settings.general.setEditToolPartsExpanded(false)
|
||||||
|
settings.general.setShowFileTree(false)
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
settings.general.setShowSearch(false)
|
||||||
setPinchZoom(checked)
|
settings.general.setShowStatus(false)
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
settings.general.setShowCustomAgents(false)
|
||||||
if (!update) return
|
|
||||||
void update.catch(() => setPinchZoom(!checked))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InterfaceSection = () => (
|
const InterfaceSection = () => (
|
||||||
@@ -329,6 +180,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
|
<WorkspaceDestinationSetting />
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<ShellSetting controller={shell} />
|
||||||
@@ -391,18 +243,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.showFileTree.title")}
|
|
||||||
description={language.t("settings.general.row.showFileTree.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-file-tree">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showFileTree()}
|
|
||||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
@@ -438,50 +278,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -515,30 +312,15 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// We can probably remove this, right?
|
|
||||||
const DisplaySection = () => (
|
|
||||||
<Show when={desktop()}>
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
|
|
||||||
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.pinchZoom.title")}
|
|
||||||
description={language.t("settings.general.row.pinchZoom.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-pinch-zoom">
|
|
||||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={restoreDefaults}>
|
||||||
|
{language.t("common.reset")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
@@ -552,18 +334,10 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection controller={appearance} />
|
|
||||||
|
|
||||||
<NotificationsSection />
|
|
||||||
|
|
||||||
<SoundsSection controller={sounds} />
|
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<DisplaySection />
|
|
||||||
|
|
||||||
<AdvancedSection />
|
<AdvancedSection />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
export { DialogSettings } from "./dialog-settings-v2"
|
export { DialogSettings } from "./dialog-settings-v2"
|
||||||
|
export { SettingsGeneralV2 } from "./general"
|
||||||
|
export { SettingsAppearanceV2 } from "./appearance"
|
||||||
|
export { SettingsNotificationsV2 } from "./notifications"
|
||||||
|
export { SettingsProvidersV2 } from "./providers"
|
||||||
|
export { SettingsModelsV2 } from "./models"
|
||||||
|
export { SettingsServersV2 } from "./servers"
|
||||||
|
export { SettingsProjectsV2 } from "./projects"
|
||||||
|
export { SettingsExtensionsV2 } from "./extensions"
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -22,7 +25,10 @@ const PROVIDER_ICON_SIZE = 16
|
|||||||
export const SettingsModelsV2: Component = () => {
|
export const SettingsModelsV2: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
const server = useServer()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
useServerSync()().loadProviders()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
const [store, setStore] = persisted(
|
const [store, setStore] = persisted(
|
||||||
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
@@ -53,7 +59,14 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||||
|
<InlineServerSelect
|
||||||
|
value={selectedServer()}
|
||||||
|
onChange={setSelectedServer}
|
||||||
|
includeAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="settings-v2-tab-search">
|
<div class="settings-v2-tab-search">
|
||||||
<TextInputV2
|
<TextInputV2
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Component } from "solid-js"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import {
|
||||||
|
createSoundSettingsController,
|
||||||
|
soundOptions,
|
||||||
|
type SoundSettingsController,
|
||||||
|
} from "./general-controllers"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
const soundSettings = {
|
||||||
|
agent: {
|
||||||
|
action: "settings-sounds-agent",
|
||||||
|
title: "settings.general.sounds.agent.title",
|
||||||
|
description: "settings.general.sounds.agent.description",
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
action: "settings-sounds-permissions",
|
||||||
|
title: "settings.general.sounds.permissions.title",
|
||||||
|
description: "settings.general.sounds.permissions.description",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
action: "settings-sounds-errors",
|
||||||
|
title: "settings.general.sounds.errors.title",
|
||||||
|
description: "settings.general.sounds.errors.description",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SoundSetting: Component<{
|
||||||
|
kind: "agent" | "permissions" | "errors"
|
||||||
|
channel: SoundSettingsController["agent"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => soundSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action={config().action}
|
||||||
|
options={soundOptions}
|
||||||
|
current={props.channel.current()}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => language.t(option.label)}
|
||||||
|
onHighlight={props.channel.highlight}
|
||||||
|
onSelect={props.channel.select}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsNotificationsV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const sounds = createSoundSettingsController()
|
||||||
|
|
||||||
|
const restoreDefaults = () => {
|
||||||
|
settings.notifications.setAgent(true)
|
||||||
|
settings.notifications.setPermissions(true)
|
||||||
|
settings.notifications.setErrors(true)
|
||||||
|
settings.sounds.setAgent("bip-bop-01")
|
||||||
|
settings.sounds.setPermissions("alert-01")
|
||||||
|
settings.sounds.setErrors("nope-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.notifications")}</h2>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={restoreDefaults}>
|
||||||
|
{language.t("common.reset")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-agent">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.agent()}
|
||||||
|
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.permissions.title")}
|
||||||
|
description={language.t("settings.general.notifications.permissions.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-permissions">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.permissions()}
|
||||||
|
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.errors.title")}
|
||||||
|
description={language.t("settings.general.notifications.errors.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-errors">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.errors()}
|
||||||
|
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SoundSetting kind="agent" channel={sounds.agent} />
|
||||||
|
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
||||||
|
<SoundSetting kind="errors" channel={sounds.errors} />
|
||||||
|
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
|
|
||||||
|
export interface ServerSelectOption {
|
||||||
|
key: ServerConnection.Key | "all"
|
||||||
|
label: string
|
||||||
|
isDefault?: boolean
|
||||||
|
isAll?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineServerSelect: Component<{
|
||||||
|
value: ServerConnection.Key | "all"
|
||||||
|
onChange: (key: ServerConnection.Key | "all") => void
|
||||||
|
includeAll?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
|
||||||
|
const hasMultipleServers = createMemo(() => global.servers.list().length > 1)
|
||||||
|
|
||||||
|
const options = createMemo<ServerSelectOption[]>(() => {
|
||||||
|
const list: ServerSelectOption[] = []
|
||||||
|
if (props.includeAll) {
|
||||||
|
list.push({
|
||||||
|
key: "all",
|
||||||
|
label: language.t("settings.server.all"),
|
||||||
|
isAll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = global.servers.list()
|
||||||
|
for (const item of servers) {
|
||||||
|
const key = ServerConnection.key(item)
|
||||||
|
const isDefault = key === server.key
|
||||||
|
list.push({
|
||||||
|
key,
|
||||||
|
label: serverName(item) || key,
|
||||||
|
isDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentOption = createMemo(() => {
|
||||||
|
return options().find((opt) => opt.key === props.value) ?? options()[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={hasMultipleServers()}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-server-select"
|
||||||
|
options={options()}
|
||||||
|
current={currentOption()}
|
||||||
|
value={(opt) => opt.key}
|
||||||
|
label={(opt) => opt.label}
|
||||||
|
disabled={props.disabled}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(opt) => {
|
||||||
|
if (opt) props.onChange(opt.key)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLayout, getProjectAvatarVariant } from "@/context/layout"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
export const SettingsProjectsV2: Component = () => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const global = useGlobal()
|
||||||
|
const layout = useLayout()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
|
|
||||||
|
const projects = createMemo(() => {
|
||||||
|
return layout.projects.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProjectSettings = (project: ReturnType<typeof layout.projects.list>[number]) => {
|
||||||
|
const currentServer = server.current ?? global.servers.list()[0]
|
||||||
|
if (!currentServer) return
|
||||||
|
dialog.push(() => <DialogEditProjectV2 project={project} server={currentServer} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect
|
||||||
|
value={selectedServer()}
|
||||||
|
onChange={setSelectedServer}
|
||||||
|
includeAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
<Show
|
||||||
|
when={projects().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||||
|
{language.t("settings.projects.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={projects()}>
|
||||||
|
{(project) => {
|
||||||
|
const name = () => project.name || project.worktree.split(/[/\\]/).pop() || project.worktree
|
||||||
|
const color = () => getProjectAvatarVariant(project.icon?.color)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
||||||
|
onClick={() => openProjectSettings(project)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={name()}
|
||||||
|
variant={color()}
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={(e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openProjectSettings(project)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
import { createMemo, createSignal, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
@@ -41,6 +43,9 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
|
const server = useServer()
|
||||||
|
const [selectedServer, setSelectedServer] = createSignal<ServerConnection.Key | "all">(server.key)
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||||
@@ -145,7 +150,14 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||||
|
<InlineServerSelect
|
||||||
|
value={selectedServer()}
|
||||||
|
onChange={setSelectedServer}
|
||||||
|
includeAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body settings-v2-providers">
|
<div class="settings-v2-tab-body settings-v2-providers">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
import { useServerManagementController } from "../dialog-select-server"
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
@@ -19,6 +19,7 @@ import "./settings-v2.css"
|
|||||||
export const SettingsServersV2: Component = () => {
|
export const SettingsServersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
const controller = useServerManagementController()
|
const controller = useServerManagementController()
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
const wslServers = useFilteredWslServers(() => store.filter)
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
@@ -27,15 +28,38 @@ export const SettingsServersV2: Component = () => {
|
|||||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isLocal = (item: ServerConnection.Any) => ServerConnection.local(item)
|
||||||
|
|
||||||
|
const isDefaultServer = (key: ServerConnection.Key, item: ServerConnection.Any) => {
|
||||||
|
const def = controller.defaultKey()
|
||||||
|
if (!def) return isLocal(item)
|
||||||
|
return def === key
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||||
const query = store.filter.trim()
|
const query = store.filter.trim()
|
||||||
if (!query) return items
|
if (!query) {
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
const aLocal = isLocal(a)
|
||||||
|
const bLocal = isLocal(b)
|
||||||
|
if (aLocal && !bLocal) return -1
|
||||||
|
if (!aLocal && bLocal) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
.go(query, items, {
|
.go(query, items, {
|
||||||
keys: [(item) => serverName(item), (item) => item.http.url],
|
keys: [(item) => serverName(item), (item) => item.http.url],
|
||||||
})
|
})
|
||||||
.map((result) => result.obj)
|
.map((result) => result.obj)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aLocal = isLocal(a)
|
||||||
|
const bLocal = isLocal(b)
|
||||||
|
if (aLocal && !bLocal) return -1
|
||||||
|
if (!aLocal && bLocal) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
@@ -97,12 +121,11 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings controller={controller} servers={wslServers} />
|
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => controller.status()[key]
|
const health = () => controller.status()[key]
|
||||||
const isDefault = () => controller.defaultKey() === key
|
const isDefault = () => isDefaultServer(key, item)
|
||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
@@ -131,6 +154,7 @@ export const SettingsServersV2: Component = () => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<WslServerSettings controller={controller} servers={wslServers} />
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-v2-tab-title {
|
.settings-v2-tab-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -684,6 +692,223 @@
|
|||||||
color: var(--v2-text-text-base);
|
color: var(--v2-text-text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||||
|
font-weight: 610;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-workspaces {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-count {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-delete-all {
|
||||||
|
color: var(--v2-state-fg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row:not(:last-child) {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row-header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-path {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-active,
|
||||||
|
.settings-v2-workspaces-more {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-sessions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 0.5px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session:not(:last-child) {
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-block: 48px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.settings-v2-workspaces-header {
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-workspaces {
|
||||||
|
padding: 0 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar,
|
||||||
|
.settings-v2-workspaces-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-path {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
@@ -727,3 +952,40 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--v2-state-fg-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
width: 280px;
|
||||||
|
height: 32px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0.5px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs > button[data-selected] {
|
||||||
|
border-color: var(--v2-border-border-muted);
|
||||||
|
background: var(--v2-background-bg-layer-03);
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs > button:focus-visible {
|
||||||
|
outline: 2px solid var(--v2-border-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { For, Show, createMemo } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import type { Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import {
|
||||||
|
containsDirectory,
|
||||||
|
filterWorkspaceInventory,
|
||||||
|
inspectWorkspaceDeletion,
|
||||||
|
mergeWorkspaceSessionInventory,
|
||||||
|
removeWorkspacesSequentially,
|
||||||
|
sessionsForWorkspace,
|
||||||
|
type WorkspaceDeleteInspection,
|
||||||
|
workspaceInventory,
|
||||||
|
} from "@/utils/workspace"
|
||||||
|
import { listAllSessions, normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
type Workspace = {
|
||||||
|
directory: string
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
project: "all",
|
||||||
|
transaction: undefined as "confirm" | "running" | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
||||||
|
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
||||||
|
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||||
|
const projectOptions = createMemo(() => [
|
||||||
|
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||||
|
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||||
|
])
|
||||||
|
const selectedProject = createMemo(() =>
|
||||||
|
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||||
|
)
|
||||||
|
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||||
|
const captureDeleteContext = () => {
|
||||||
|
const sdk = serverSDK()
|
||||||
|
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||||
|
}
|
||||||
|
const loadSessions = async (context = captureDeleteContext()) => {
|
||||||
|
const protocol = await context.sdk.protocol
|
||||||
|
const fetched =
|
||||||
|
protocol === "v1"
|
||||||
|
? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => {
|
||||||
|
if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory")
|
||||||
|
return response.data.map(normalizeSessionInfo)
|
||||||
|
})
|
||||||
|
: await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||||
|
return mergeWorkspaceSessionInventory(
|
||||||
|
fetched,
|
||||||
|
Object.values(context.sync.session.data.info).filter((session): session is Session => !!session),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const sessionQuery = useQuery(() => ({
|
||||||
|
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||||
|
queryFn: () => loadSessions(),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
}))
|
||||||
|
const workspaceSessions = (workspace: Workspace) => {
|
||||||
|
if (!sessionQuery.isSuccess) return []
|
||||||
|
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
||||||
|
}
|
||||||
|
const sessionCount = (workspace: Workspace) => {
|
||||||
|
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||||
|
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||||
|
const count = workspaceSessions(workspace).length
|
||||||
|
return language.plural("settings.workspaces.sessions", count, {
|
||||||
|
count,
|
||||||
|
project: projectName(workspace.project),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const lastActive = (workspace: Workspace) => {
|
||||||
|
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||||
|
if (!updated) return undefined
|
||||||
|
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||||
|
}
|
||||||
|
const sessionTime = (session: Session) => {
|
||||||
|
if (!session.time.updated) return undefined
|
||||||
|
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||||
|
const [working, branch, sessions] = await Promise.all([
|
||||||
|
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||||
|
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||||
|
loadSessions(context),
|
||||||
|
])
|
||||||
|
const result = inspectWorkspaceDeletion({
|
||||||
|
workspace: workspace.directory,
|
||||||
|
activeDirectory: context.activeDirectory,
|
||||||
|
sessions,
|
||||||
|
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||||
|
})
|
||||||
|
return { result, sessions }
|
||||||
|
}
|
||||||
|
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||||
|
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||||
|
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||||
|
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||||
|
return language.t("workspace.status.clean")
|
||||||
|
}
|
||||||
|
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: inspectionMessage(result),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||||
|
const preflight = await inspect(workspace, context)
|
||||||
|
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||||
|
blocked(preflight.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const removed = await context.sdk.client.worktree
|
||||||
|
.remove({
|
||||||
|
directory: workspace.project.worktree,
|
||||||
|
worktreeRemoveInput: { directory: workspace.directory },
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch((error) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!removed) return
|
||||||
|
tabs.store.forEach((tab) => {
|
||||||
|
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||||
|
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||||
|
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||||
|
if (!directoryMatches && !worktreeMatches) return
|
||||||
|
tabs.updateDraft(tab.draftID, {
|
||||||
|
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||||
|
worktree: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
clearWorkspaceTerminals(
|
||||||
|
workspace.directory,
|
||||||
|
preflight.sessions.map((session) => session.id),
|
||||||
|
platform,
|
||||||
|
context.sdk.scope,
|
||||||
|
)
|
||||||
|
context.sync.set(
|
||||||
|
"project",
|
||||||
|
produce((draft) => {
|
||||||
|
const project = draft.find((item) => item.id === workspace.project.id)
|
||||||
|
if (!project) return
|
||||||
|
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||||
|
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspectionID = 0
|
||||||
|
const releaseConfirmation = () => {
|
||||||
|
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||||
|
}
|
||||||
|
const transact = async (task: () => Promise<void>) => {
|
||||||
|
if (store.transaction !== "confirm") return
|
||||||
|
setStore("transaction", "running")
|
||||||
|
try {
|
||||||
|
await task()
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setStore("transaction", undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const confirmDelete = (workspace: Workspace) => {
|
||||||
|
if (store.transaction) return
|
||||||
|
const context = captureDeleteContext()
|
||||||
|
const current = ++inspectionID
|
||||||
|
setStore("transaction", "confirm")
|
||||||
|
void dialog.push(
|
||||||
|
() => (
|
||||||
|
<DialogDeleteWorkspace
|
||||||
|
workspace={workspace}
|
||||||
|
scope={context.sdk.scope}
|
||||||
|
inspectionID={current}
|
||||||
|
inspect={() => inspect(workspace, context)}
|
||||||
|
inspectionMessage={inspectionMessage}
|
||||||
|
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
releaseConfirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||||
|
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||||
|
}
|
||||||
|
const confirmDeleteAll = () => {
|
||||||
|
if (store.transaction) return
|
||||||
|
const context = captureDeleteContext()
|
||||||
|
const inventory = [...filtered()]
|
||||||
|
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||||
|
setStore("transaction", "confirm")
|
||||||
|
void dialog.push(
|
||||||
|
() => (
|
||||||
|
<DialogDeleteAllWorkspaces
|
||||||
|
count={inventory.length}
|
||||||
|
project={project}
|
||||||
|
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
releaseConfirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||||
|
<div class="settings-v2-workspaces-toolbar">
|
||||||
|
<span class="settings-v2-workspaces-count">
|
||||||
|
{language.plural("settings.workspaces.count", filtered().length)}
|
||||||
|
</span>
|
||||||
|
<div class="settings-v2-workspaces-toolbar-actions">
|
||||||
|
<Show when={projects().length > 1}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
options={projectOptions()}
|
||||||
|
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.label}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(option) => option && setStore("project", option.id)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={filtered().length > 0}>
|
||||||
|
<MenuV2 placement="bottom-end" gutter={4}>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
disabled={!!store.transaction}
|
||||||
|
icon={<Icon name="outline-dots" size="small" />}
|
||||||
|
/>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||||
|
<span class="settings-v2-workspaces-delete-all">
|
||||||
|
{language.t("settings.workspaces.deleteAll")}
|
||||||
|
</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-workspaces-inventory">
|
||||||
|
<Show
|
||||||
|
when={filtered().length > 0}
|
||||||
|
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||||
|
>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={filtered()}>
|
||||||
|
{(workspace) => {
|
||||||
|
const linked = () => workspaceSessions(workspace)
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-workspaces-row">
|
||||||
|
<div class="settings-v2-workspaces-row-header">
|
||||||
|
<div class="settings-v2-workspaces-copy">
|
||||||
|
<div class="settings-v2-workspaces-main">
|
||||||
|
<TooltipV2
|
||||||
|
value={workspace.directory}
|
||||||
|
placement="top-start"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
||||||
|
{workspace.directory}
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-workspaces-row-actions">
|
||||||
|
<Show when={lastActive(workspace)}>
|
||||||
|
{(value) => (
|
||||||
|
<TooltipV2
|
||||||
|
value={language.t("settings.workspaces.lastActiveSession")}
|
||||||
|
placement="top-end"
|
||||||
|
>
|
||||||
|
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||||
|
{value()}
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
aria-label={language.t("workspace.delete.confirm", {
|
||||||
|
name: getFilename(workspace.directory),
|
||||||
|
})}
|
||||||
|
disabled={!!store.transaction}
|
||||||
|
icon={<Icon name="trash" size="small" />}
|
||||||
|
onClick={() => confirmDelete(workspace)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={linked().length > 0}>
|
||||||
|
<div class="settings-v2-workspaces-sessions">
|
||||||
|
<For each={linked()}>
|
||||||
|
{(session) => (
|
||||||
|
<div class="settings-v2-workspaces-session">
|
||||||
|
<span>{session.title}</span>
|
||||||
|
<Show when={sessionTime(session)}>
|
||||||
|
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const remove = () => {
|
||||||
|
const deleting = props.onDelete()
|
||||||
|
dialog.close()
|
||||||
|
void deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("settings.workspaces.deleteAll")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||||
|
<br />
|
||||||
|
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||||
|
{language.t("settings.workspaces.deleteAll")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteWorkspace(props: {
|
||||||
|
workspace: Workspace
|
||||||
|
scope: ServerScope
|
||||||
|
inspectionID: number
|
||||||
|
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
|
||||||
|
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
||||||
|
onDelete: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const status = useQuery(() => ({
|
||||||
|
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||||
|
queryFn: props.inspect,
|
||||||
|
staleTime: 0,
|
||||||
|
}))
|
||||||
|
const description = () => {
|
||||||
|
if (status.isPending) return language.t("workspace.status.checking")
|
||||||
|
if (status.isError) return language.t("workspace.status.error")
|
||||||
|
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||||
|
}
|
||||||
|
const remove = () => {
|
||||||
|
const deleting = props.onDelete()
|
||||||
|
dialog.close()
|
||||||
|
void deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("workspace.delete.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||||
|
<br />
|
||||||
|
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||||
|
{props.workspace.directory}
|
||||||
|
</code>
|
||||||
|
<br />
|
||||||
|
{language.t("settings.workspaces.delete.warning")}
|
||||||
|
<br />
|
||||||
|
{description()}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
disabled={
|
||||||
|
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
||||||
|
}
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
{language.t("workspace.delete.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -152,12 +152,6 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
|
||||||
() =>
|
|
||||||
input.queryClient.fetchQuery(
|
|
||||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
|
||||||
),
|
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
@@ -524,17 +518,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||||
)),
|
)),
|
||||||
() =>
|
|
||||||
input.queryClient
|
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
|
||||||
.catch((err) => {
|
|
||||||
const project = getFilename(input.directory)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
|
||||||
description: formatServerError(err, input.translate),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|||||||
@@ -263,10 +263,13 @@ describe("createChildStoreManager", () => {
|
|||||||
manager.child("/project")
|
manager.child("/project")
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[3]?.().enabled).toBe(true)
|
expect(queries[3]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
expect(queries[5]?.().enabled).toBe(true)
|
expect(queries[5]?.().enabled).toBe(true)
|
||||||
expect(bootstraps).toEqual(["/project"])
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
|
manager.enableProviders("/project")
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
|
|
||||||
manager.child("/project", { bootstrap: false })
|
manager.child("/project", { bootstrap: false })
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function createChildStoreManager(input: {
|
|||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
const activeDirectories = new Set<string>()
|
const activeDirectories = new Set<string>()
|
||||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
const providerToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
@@ -122,6 +123,7 @@ export function createChildStoreManager(input: {
|
|||||||
mcpToggles.delete(key)
|
mcpToggles.delete(key)
|
||||||
activeDirectories.delete(key)
|
activeDirectories.delete(key)
|
||||||
activationToggles.delete(key)
|
activationToggles.delete(key)
|
||||||
|
providerToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -187,6 +189,7 @@ export function createChildStoreManager(input: {
|
|||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
const [providerEnabled, setProviderEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
@@ -194,7 +197,7 @@ export function createChildStoreManager(input: {
|
|||||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const providerQuery = useQuery(() => ({
|
const providerQuery = useQuery(() => ({
|
||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: instanceQueriesEnabled(),
|
enabled: providerEnabled(),
|
||||||
}))
|
}))
|
||||||
const referenceQuery = useQuery(() => ({
|
const referenceQuery = useQuery(() => ({
|
||||||
...input.queryOptions.references(key),
|
...input.queryOptions.references(key),
|
||||||
@@ -206,7 +209,7 @@ export function createChildStoreManager(input: {
|
|||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
return providerEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
@@ -263,6 +266,7 @@ export function createChildStoreManager(input: {
|
|||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
providerToggles.set(key, setProviderEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
@@ -329,6 +333,12 @@ export function createChildStoreManager(input: {
|
|||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableProviders(directory: string) {
|
||||||
|
const key = directoryKey(directory)
|
||||||
|
ensureChild(directory)
|
||||||
|
providerToggles.get(key)?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Passive Home/project metadata reads must not initialize the directory.
|
// Passive Home/project metadata reads must not initialize the directory.
|
||||||
// A real directory access enables these queries once for the store lifetime.
|
// A real directory access enables these queries once for the store lifetime.
|
||||||
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||||
@@ -387,6 +397,7 @@ export function createChildStoreManager(input: {
|
|||||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||||
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||||
disableMcp,
|
disableMcp,
|
||||||
|
enableProviders,
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
runEviction,
|
runEviction,
|
||||||
vcsCache,
|
vcsCache,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index",
|
|||||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||||
|
|
||||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||||
|
type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } }
|
||||||
|
|
||||||
export async function loadHomeSessionIndex(
|
export async function loadHomeSessionIndex(
|
||||||
list: (
|
list: (
|
||||||
@@ -31,7 +32,30 @@ export async function loadHomeSessionIndex(
|
|||||||
eventSequence = 0,
|
eventSequence = 0,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const data: SessionV2Info[] = []
|
return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProjectedHomeSessionIndex(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<ProjectedHomeSessionPage>,
|
||||||
|
eventSequence = 0,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHomeSessionPages<T>(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>,
|
||||||
|
project: (sessions: T[]) => Session[],
|
||||||
|
eventSequence: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const data: T[] = []
|
||||||
let cursor: string | undefined
|
let cursor: string | undefined
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -46,7 +70,7 @@ export async function loadHomeSessionIndex(
|
|||||||
const page = response.data!
|
const page = response.data!
|
||||||
data.push(...page.data)
|
data.push(...page.data)
|
||||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
return { sessions: project(data), eventSequence }
|
||||||
cursor = page.cursor.next
|
cursor = page.cursor.next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
|||||||
import { createEffect, createMemo, createResource } from "solid-js"
|
import { createEffect, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
import {
|
||||||
|
pluralCategory,
|
||||||
|
type UiI18nPluralLookupKey,
|
||||||
|
type UiI18nPluralKey,
|
||||||
|
type UiPluralCategory,
|
||||||
|
} from "@opencode-ai/ui/context/i18n"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
@@ -25,11 +30,13 @@ function localeDirection(locale: Locale): Direction {
|
|||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
type PluralKey =
|
type AppI18nKey = Extract<keyof typeof en, string>
|
||||||
| UiI18nPluralKey
|
type AppI18nPluralKey = {
|
||||||
| "session.question.pending"
|
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||||
| "session.followupDock.summary"
|
}[AppI18nKey]
|
||||||
| "session.revertDock.summary"
|
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||||
|
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||||
|
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
|
||||||
type Source = { dict: Record<string, string> }
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
@@ -69,7 +76,7 @@ const INTL: Record<Locale, string> = {
|
|||||||
sv: "sv-SE",
|
sv: "sv-SE",
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
|
const LABEL_KEY = {
|
||||||
en: "language.en",
|
en: "language.en",
|
||||||
zh: "language.zh",
|
zh: "language.zh",
|
||||||
zht: "language.zht",
|
zht: "language.zht",
|
||||||
@@ -88,7 +95,7 @@ const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
|
|||||||
th: "language.th",
|
th: "language.th",
|
||||||
bs: "language.bs",
|
bs: "language.bs",
|
||||||
tr: "language.tr",
|
tr: "language.tr",
|
||||||
}
|
} as const satisfies Partial<Record<Locale, AppI18nKey>>
|
||||||
|
|
||||||
const LABEL: Partial<Record<Locale, string>> = {
|
const LABEL: Partial<Record<Locale, string>> = {
|
||||||
hi: "हिन्दी",
|
hi: "हिन्दी",
|
||||||
@@ -259,21 +266,26 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
initialValue: dicts.get(initial) ?? base,
|
initialValue: dicts.get(initial) ?? base,
|
||||||
})
|
})
|
||||||
|
|
||||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
|
||||||
key: keyof Dictionary,
|
key: TranslationKey<Key>,
|
||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
) => string
|
) => string
|
||||||
|
|
||||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
const pluralForm = (
|
||||||
const category = pluralCategory(intl(), count)
|
key: PluralKey,
|
||||||
|
category: UiPluralCategory,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
) => {
|
||||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||||
const candidate = `${key}.${category}`
|
const candidate = `${key}.${category}`
|
||||||
const fallback = `${key}.other`
|
const fallback = `${key}.other`
|
||||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||||
}
|
}
|
||||||
|
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||||
|
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||||
|
|
||||||
const label = (value: Locale) => {
|
const label = (value: Locale) => {
|
||||||
const key = LABEL_KEY[value]
|
const key = LABEL_KEY[value as keyof typeof LABEL_KEY]
|
||||||
if (key) return t(key)
|
if (key) return t(key)
|
||||||
return LABEL[value] ?? value
|
return LABEL[value] ?? value
|
||||||
}
|
}
|
||||||
@@ -305,6 +317,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
plural,
|
plural,
|
||||||
|
pluralForm,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", normalizeLocale(next))
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
|||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import {
|
import {
|
||||||
createPromptReady,
|
createPromptReady,
|
||||||
createPromptSession,
|
createPromptSession,
|
||||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||||
const scope = (): PromptScope =>
|
const scope = (): PromptScope =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: PromptScope) => {
|
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
const current = settings.general.newLayoutDesigns()
|
||||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||||
|
: undefined
|
||||||
|
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||||
|
|
||||||
const key = scopeKey(scope)
|
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot(
|
||||||
(dispose) => ({
|
(dispose) => ({
|
||||||
value: createPromptSession(serverSDK().scope, scope),
|
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||||
dispose,
|
dispose,
|
||||||
}),
|
}),
|
||||||
owner,
|
owner,
|
||||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(scope()))
|
const session = createMemo(() => load(scope()))
|
||||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
scope ? load(scope, target) : session()
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
pick(scope, target).capture(),
|
||||||
current: withSuspense(() => session().current()),
|
current: withSuspense(() => session().current()),
|
||||||
cursor: withSuspense(() => session().cursor()),
|
cursor: withSuspense(() => session().cursor()),
|
||||||
dirty: withSuspense(() => session().dirty()),
|
dirty: withSuspense(() => session().dirty()),
|
||||||
|
|||||||
@@ -1,7 +1,89 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import {
|
||||||
|
adaptServerEvent,
|
||||||
|
adaptWorktreeCompatibilityEvent,
|
||||||
|
applyWorkspaceOperationEvent,
|
||||||
|
applyWorktreeEvent,
|
||||||
|
coalesceServerEvents,
|
||||||
|
enqueueServerEvent,
|
||||||
|
resumeStreamAfterPageShow,
|
||||||
|
} from "./server-sdk"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
|
||||||
|
describe("applyWorktreeEvent", () => {
|
||||||
|
test("adapts global readiness with the created worktree directory", () => {
|
||||||
|
const directory = "/repo/worktree-compatible"
|
||||||
|
const event = adaptWorktreeCompatibilityEvent({
|
||||||
|
directory,
|
||||||
|
payload: { id: "ready", type: "worktree.ready", properties: { name: "compatible" } } as Event,
|
||||||
|
})
|
||||||
|
if (!event) throw new Error("expected worktree event")
|
||||||
|
|
||||||
|
Worktree.pending(ServerScope.local, directory)
|
||||||
|
applyWorktreeEvent(ServerScope.local, event, "failed")
|
||||||
|
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
|
||||||
|
expect(
|
||||||
|
adaptWorktreeCompatibilityEvent({
|
||||||
|
directory: "/repo",
|
||||||
|
payload: {
|
||||||
|
id: "status",
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session", status: { type: "idle" } },
|
||||||
|
} as Event,
|
||||||
|
}),
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the server failure message", () => {
|
||||||
|
const directory = "/repo/worktree-failed"
|
||||||
|
Worktree.pending(ServerScope.local, directory)
|
||||||
|
|
||||||
|
applyWorktreeEvent(
|
||||||
|
ServerScope.local,
|
||||||
|
{
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
id: "failed",
|
||||||
|
type: "worktree.failed",
|
||||||
|
properties: { name: "failed", message: "bootstrap failed" },
|
||||||
|
} as Event,
|
||||||
|
},
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("legacy and current moved events complete matching operations", () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: "legacy",
|
||||||
|
payload: {
|
||||||
|
id: "moved",
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } },
|
||||||
|
} as Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "current",
|
||||||
|
payload: adaptServerEvent({
|
||||||
|
id: "moved-current",
|
||||||
|
created: Date.now(),
|
||||||
|
type: "session.moved",
|
||||||
|
data: { sessionID: "current", location: { directory: "/workspace" } },
|
||||||
|
} as OpenCodeEvent),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
events.forEach((event) => {
|
||||||
|
WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace")
|
||||||
|
applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload })
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { useGlobal } from "./global"
|
|||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||||
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { decodeVcsDiff } from "@/utils/vcs-diff-decoder"
|
||||||
|
import { decodeSessionList } from "./session-message-decoder"
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
@@ -56,6 +60,11 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adaptWorktreeCompatibilityEvent(event: { directory?: string; payload: Event }) {
|
||||||
|
if (event.payload.type !== "worktree.ready" && event.payload.type !== "worktree.failed") return
|
||||||
|
return { directory: event.directory ?? "global", payload: event.payload }
|
||||||
|
}
|
||||||
|
|
||||||
const coalescedKey = (event: QueuedServerEvent) => {
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
if (event.payload.type === "message.part.updated") {
|
if (event.payload.type === "message.part.updated") {
|
||||||
@@ -138,6 +147,31 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
|
||||||
|
if (event.payload.type === "worktree.ready") {
|
||||||
|
Worktree.ready(scope, event.directory)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.payload.type !== "worktree.failed") return false
|
||||||
|
const message = event.payload.properties.message ?? fallback
|
||||||
|
Worktree.failed(scope, event.directory, message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||||
|
if (event.payload.current?.type === "session.moved") {
|
||||||
|
WorkspaceOperation.complete(
|
||||||
|
scope,
|
||||||
|
event.payload.current.data.sessionID,
|
||||||
|
event.payload.current.data.location.directory,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.payload.type !== "session.next.moved") return false
|
||||||
|
WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||||
if (
|
if (
|
||||||
event?.type === "session.text.delta" ||
|
event?.type === "session.text.delta" ||
|
||||||
@@ -186,6 +220,7 @@ type ServerSDKBase = {
|
|||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
const eventFetch = (() => {
|
const eventFetch = (() => {
|
||||||
@@ -238,7 +273,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => {
|
||||||
|
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
|
||||||
|
applyWorkspaceOperationEvent(scope, event)
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
buffer.length = 0
|
buffer.length = 0
|
||||||
@@ -251,12 +290,57 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
|
|
||||||
let streamErrorLogged = false
|
let streamErrorLogged = false
|
||||||
|
let worktreeStreamErrorLogged = false
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
let attempt: AbortController | undefined
|
let attempt: AbortController | undefined
|
||||||
|
let worktreeAttempt: AbortController | undefined
|
||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
|
const consumeWorktreeEvents = async (active: number) => {
|
||||||
|
// Current worktree lifecycle events are still emitted only on the global compatibility stream.
|
||||||
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
worktreeAttempt = controller
|
||||||
|
const onAbort = () => controller.abort()
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
|
try {
|
||||||
|
const events = (await eventSdk.global.event({ signal: controller.signal })).stream
|
||||||
|
let yielded = Date.now()
|
||||||
|
for await (const event of events) {
|
||||||
|
const queued = adaptWorktreeCompatibilityEvent({
|
||||||
|
directory: event.directory,
|
||||||
|
payload: event.payload as Event,
|
||||||
|
})
|
||||||
|
if (queued) {
|
||||||
|
worktreeStreamErrorLogged = false
|
||||||
|
if (enqueueServerEvent(queue, queued)) schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await wait(0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) {
|
||||||
|
worktreeStreamErrorLogged = true
|
||||||
|
console.error("[global-sdk] worktree event stream failed", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
if (worktreeAttempt === controller) worktreeAttempt = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
await wait(RECONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
if (started) return run
|
if (started) return run
|
||||||
started = true
|
started = true
|
||||||
@@ -264,6 +348,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
const previous = run
|
const previous = run
|
||||||
const current = (async () => {
|
const current = (async () => {
|
||||||
if (previous) await previous
|
if (previous) await previous
|
||||||
|
const kind = await protocol
|
||||||
|
if (kind === "v2") void consumeWorktreeEvents(active)
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||||
while (!abort.signal.aborted && started && generation === active) {
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
attempt = new AbortController()
|
attempt = new AbortController()
|
||||||
@@ -272,7 +358,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const kind = await protocol
|
|
||||||
const events =
|
const events =
|
||||||
kind === "v1"
|
kind === "v1"
|
||||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||||
@@ -320,6 +405,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
started = false
|
started = false
|
||||||
generation++
|
generation++
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
|
worktreeAttempt?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -346,7 +432,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
directory,
|
directory,
|
||||||
})
|
})
|
||||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
@@ -432,7 +518,19 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
|||||||
current: serverSDK.currentApi,
|
current: serverSDK.currentApi,
|
||||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||||
directory,
|
directory,
|
||||||
|
decodeVcsDiff,
|
||||||
|
decodeSessionList,
|
||||||
}),
|
}),
|
||||||
|
createApi(next: string) {
|
||||||
|
return createCompatibleApi({
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
|
current: serverSDK.currentApi,
|
||||||
|
legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }),
|
||||||
|
directory: next,
|
||||||
|
decodeVcsDiff,
|
||||||
|
decodeSessionList,
|
||||||
|
})
|
||||||
|
},
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies moved session locations without evicting cached state", () => {
|
||||||
|
const current = { ...session("child"), directory: "/repo/worktree", path: undefined }
|
||||||
|
const ctx = setup({ child: current })
|
||||||
|
ctx.store.remember(current)
|
||||||
|
|
||||||
|
ctx.store.apply({
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: {
|
||||||
|
sessionID: "child",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
subdirectory: "packages/app",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" })
|
||||||
|
})
|
||||||
|
|
||||||
test("loads session content through the server client", async () => {
|
test("loads session content through the server client", async () => {
|
||||||
const ctx = setup({ root: session("root") })
|
const ctx = setup({ root: session("root") })
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { normalizeSessionMessages } from "@/utils/session-message"
|
|||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||||
|
|
||||||
type MessageApi = ServerApi["message"]
|
type MessageApi = ServerApi["message"]
|
||||||
|
|
||||||
@@ -29,10 +30,16 @@ 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 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 SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 20
|
const initialMessagePageSize = 20
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 50
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
function yieldToMain() {
|
||||||
|
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||||
|
if (scheduler) return scheduler.yield()
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||||
const boundary = source.find(
|
const boundary = source.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
@@ -183,7 +190,11 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
type ServerSessionOptions = {
|
||||||
|
retry?: typeof retry
|
||||||
|
protocol?: Promise<"v1" | "v2">
|
||||||
|
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
|
||||||
|
}
|
||||||
|
|
||||||
export function createServerSession(
|
export function createServerSession(
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
@@ -301,22 +312,24 @@ export function createServerSession(
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||||
const cached = data.info[sessionID]
|
const cached = data.info[sessionID]
|
||||||
if (cached && !options?.force) return Promise.resolve(cached)
|
if (cached && !options?.force) return Promise.resolve(cached)
|
||||||
const pending = requests.get(sessionID)
|
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = sessionApi
|
const request = sessionApi
|
||||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo)
|
||||||
: client.session.get({ sessionID }).then((result) => {
|
: client.session.get({ sessionID }, { signal: options?.signal }).then((result) => {
|
||||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||||
return result.data
|
return result.data
|
||||||
})
|
})
|
||||||
const resolved = request.then((result) => {
|
const resolved = request.then((result) => {
|
||||||
|
if (options?.signal?.aborted) return result
|
||||||
if (generations.get(sessionID) !== active) return result
|
if (generations.get(sessionID) !== active) return result
|
||||||
return remember(result)
|
return remember(result)
|
||||||
})
|
})
|
||||||
|
if (options?.signal) return resolved
|
||||||
requests.set(sessionID, resolved)
|
requests.set(sessionID, resolved)
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||||
@@ -552,6 +565,7 @@ export function createServerSession(
|
|||||||
if (!response.data.length) break
|
if (!response.data.length) break
|
||||||
}
|
}
|
||||||
const response = pages.at(-1)!
|
const response = pages.at(-1)!
|
||||||
|
await yieldToMain()
|
||||||
const source = pages.flatMap((page) => page.data).toReversed()
|
const source = pages.flatMap((page) => page.data).toReversed()
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
@@ -566,10 +580,21 @@ export function createServerSession(
|
|||||||
complete: response.data.length === 0,
|
complete: response.data.length === 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const response = await (options?.retry ?? retry)(async () => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.messages({ sessionID, limit, before })
|
if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before })
|
||||||
|
const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" })
|
||||||
|
if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer")
|
||||||
|
return { response, decoded: await options.decodeMessages(response.data) }
|
||||||
})
|
})
|
||||||
|
await yieldToMain()
|
||||||
|
if ("decoded" in response)
|
||||||
|
return {
|
||||||
|
...response.decoded,
|
||||||
|
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||||
|
cursor: response.response.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
|
complete: !response.response.response.headers.get("x-next-cursor"),
|
||||||
|
}
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -1018,6 +1043,27 @@ export function createServerSession(
|
|||||||
evict([sessionID])
|
evict([sessionID])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "session.next.moved": {
|
||||||
|
const props = event.properties as {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
location: { directory: string; workspaceID?: string }
|
||||||
|
subdirectory?: string
|
||||||
|
}
|
||||||
|
const current = data.info[props.sessionID]
|
||||||
|
if (!current) {
|
||||||
|
void resolve(props.sessionID, { force: true }).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remember({
|
||||||
|
...current,
|
||||||
|
directory: props.location.directory,
|
||||||
|
path: props.subdirectory,
|
||||||
|
workspaceID: props.location.workspaceID,
|
||||||
|
time: { ...current.time, updated: props.timestamp },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
@@ -1341,6 +1387,7 @@ export function createServerSession(
|
|||||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
if (!items)
|
if (!items)
|
||||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
|
indexLegacyMessage(input.message)
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
@@ -1374,6 +1421,9 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setData("session_message", input.sessionID, (messages) =>
|
||||||
|
messages?.filter((message) => message.id !== input.messageID),
|
||||||
|
)
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import type {
|
import type {
|
||||||
McpListInput,
|
McpListInput,
|
||||||
McpResourceCatalogInput,
|
McpResourceCatalogInput,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionListInput,
|
SessionListInput,
|
||||||
|
OpenCodeEvent,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
import {
|
||||||
|
captureSessionMove,
|
||||||
|
loadActiveSessionsQuery,
|
||||||
|
loadMcpQuery,
|
||||||
|
loadMcpResourcesQuery,
|
||||||
|
seedActiveSessionStatuses,
|
||||||
|
} from "./server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
|
import { adaptServerEvent } from "./server-sdk"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
@@ -102,6 +110,52 @@ describe("active session query", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("session move normalization", () => {
|
||||||
|
test("captures and applies current moves from the source placement", () => {
|
||||||
|
const session = createServerSession({} as OpencodeClient)
|
||||||
|
session.remember(sessionAt("/source"))
|
||||||
|
const current = {
|
||||||
|
id: "event-current-move",
|
||||||
|
created: 10,
|
||||||
|
type: "session.moved",
|
||||||
|
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||||
|
} as OpenCodeEvent
|
||||||
|
const event = adaptServerEvent(current)
|
||||||
|
|
||||||
|
expect(captureSessionMove(event, session.get)).toEqual({
|
||||||
|
sessionID: "session",
|
||||||
|
from: "/source",
|
||||||
|
refresh: "session.next.moved",
|
||||||
|
})
|
||||||
|
session.applyV2(current)
|
||||||
|
session.apply(event)
|
||||||
|
expect(session.get("session")?.directory).toBe("/destination")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("captures and applies V1 moves from the source placement", () => {
|
||||||
|
const session = createServerSession({} as OpencodeClient)
|
||||||
|
session.remember(sessionAt("/source"))
|
||||||
|
const event = {
|
||||||
|
type: "session.next.moved",
|
||||||
|
properties: {
|
||||||
|
timestamp: 10,
|
||||||
|
sessionID: "session",
|
||||||
|
location: { directory: "/destination" },
|
||||||
|
subdirectory: "packages/app",
|
||||||
|
},
|
||||||
|
} as Event
|
||||||
|
|
||||||
|
expect(captureSessionMove(event, session.get)).toEqual({
|
||||||
|
sessionID: "session",
|
||||||
|
from: "/source",
|
||||||
|
refresh: "session.next.moved",
|
||||||
|
})
|
||||||
|
session.apply(event)
|
||||||
|
expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" })
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
const now = 5_000
|
const now = 5_000
|
||||||
@@ -174,6 +228,18 @@ function sessionInfo(id: string) {
|
|||||||
} as SessionInfo
|
} as SessionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionAt(directory: string): Session {
|
||||||
|
return {
|
||||||
|
id: "session",
|
||||||
|
slug: "session",
|
||||||
|
projectID: "project",
|
||||||
|
directory,
|
||||||
|
title: "Session",
|
||||||
|
version: "",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("estimateRootSessionTotal", () => {
|
describe("estimateRootSessionTotal", () => {
|
||||||
test("keeps exact total for full fetches", () => {
|
test("keeps exact total for full fetches", () => {
|
||||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { ServerSDK } from "./server-sdk"
|
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -46,6 +46,32 @@ import { ServerConnection, useServer } from "./server"
|
|||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||||
|
|
||||||
|
export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) {
|
||||||
|
const sessionID =
|
||||||
|
event.current?.type === "session.moved"
|
||||||
|
? event.current.data.sessionID
|
||||||
|
: event.type === "session.next.moved"
|
||||||
|
? event.properties.sessionID
|
||||||
|
: undefined
|
||||||
|
if (!sessionID) return
|
||||||
|
return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
|
const type: string = event.type
|
||||||
|
const current: string | undefined = event.current?.type
|
||||||
|
return (
|
||||||
|
type === "session.created" ||
|
||||||
|
type === "session.updated" ||
|
||||||
|
type === "session.deleted" ||
|
||||||
|
type === "session.next.moved" ||
|
||||||
|
current === "session.moved" ||
|
||||||
|
current === "session.renamed" ||
|
||||||
|
current === "session.archived" ||
|
||||||
|
current === "session.forked"
|
||||||
|
)
|
||||||
|
}
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import type {
|
import type {
|
||||||
@@ -59,6 +85,7 @@ import type {
|
|||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
|
import { decodeSessionMessages } from "./session-message-decoder"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -226,6 +253,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||||
protocol: serverSDK.protocol,
|
protocol: serverSDK.protocol,
|
||||||
|
decodeMessages: decodeSessionMessages,
|
||||||
})
|
})
|
||||||
const queryOptionsApi = makeQueryOptionsApi(
|
const queryOptionsApi = makeQueryOptionsApi(
|
||||||
serverSDK.scope,
|
serverSDK.scope,
|
||||||
@@ -235,31 +263,40 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
serverSDK.protocol,
|
serverSDK.protocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [providersEnabled, setProvidersEnabled] = createSignal(false)
|
||||||
|
const [backgroundEnabled, setBackgroundEnabled] = createSignal(false)
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
queries: [
|
||||||
|
{ ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() },
|
||||||
|
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
|
||||||
|
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() =>
|
const activeSessionsQuery = useQuery(() =>
|
||||||
loadActiveSessionsQuery(serverSDK.scope, {
|
({
|
||||||
active: async () => {
|
...loadActiveSessionsQuery(serverSDK.scope, {
|
||||||
if ((await serverSDK.protocol) === "v1") {
|
active: async () => {
|
||||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
if ((await serverSDK.protocol) === "v1") {
|
||||||
seedActiveSessionStatuses(session, statuses)
|
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||||
for (const sessionID of Object.keys(statuses)) {
|
seedActiveSessionStatuses(session, statuses)
|
||||||
|
for (const sessionID of Object.keys(statuses)) {
|
||||||
|
void session.resolve(sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||||
|
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const active = await serverSDK.api.session.active()
|
||||||
|
seedActiveSessionStatuses(session, active)
|
||||||
|
for (const sessionID of Object.keys(active)) {
|
||||||
void session.resolve(sessionID).catch(() => undefined)
|
void session.resolve(sessionID).catch(() => undefined)
|
||||||
}
|
}
|
||||||
return Object.fromEntries(
|
return active
|
||||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
},
|
||||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
}),
|
||||||
),
|
enabled: backgroundEnabled(),
|
||||||
)
|
|
||||||
}
|
|
||||||
const active = await serverSDK.api.session.active()
|
|
||||||
seedActiveSessionStatuses(session, active)
|
|
||||||
for (const sessionID of Object.keys(active)) {
|
|
||||||
void session.resolve(sessionID).catch(() => undefined)
|
|
||||||
}
|
|
||||||
return active
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -299,10 +336,36 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
let eventFrame: number | undefined
|
let eventFrame: number | undefined
|
||||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let providerFrame: number | undefined
|
||||||
|
let providerIdle: number | undefined
|
||||||
|
let providerTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
providerFrame = requestAnimationFrame(() => {
|
||||||
|
providerFrame = requestAnimationFrame(() => {
|
||||||
|
providerFrame = undefined
|
||||||
|
providerTimer = setTimeout(() => {
|
||||||
|
providerTimer = undefined
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
providerIdle = requestIdleCallback(() => {
|
||||||
|
setProvidersEnabled(true)
|
||||||
|
setBackgroundEnabled(true)
|
||||||
|
}, { timeout: 5_000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProvidersEnabled(true)
|
||||||
|
setBackgroundEnabled(true)
|
||||||
|
}, 10_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||||
|
if (providerFrame !== undefined) cancelAnimationFrame(providerFrame)
|
||||||
|
if (providerIdle !== undefined) cancelIdleCallback(providerIdle)
|
||||||
|
if (providerTimer !== undefined) clearTimeout(providerTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||||
@@ -528,19 +591,55 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reindexSession = (sessionID: string, from?: string) => {
|
||||||
|
const next = session.get(sessionID)
|
||||||
|
if (!next) return
|
||||||
|
indexSession(next)
|
||||||
|
if (!from) return
|
||||||
|
const source = children.children[directoryKey(from)]
|
||||||
|
if (!source) return
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: {
|
||||||
|
type: "session.moved",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
projectID: next.projectID,
|
||||||
|
location: { directory: next.directory, workspaceID: next.workspaceID },
|
||||||
|
subpath: next.path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: from,
|
||||||
|
store: source[0],
|
||||||
|
setStore: source[1],
|
||||||
|
push: queue.push,
|
||||||
|
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||||
|
sessionContent: false,
|
||||||
|
permission: session.data.permission,
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const eventType: string = event.type
|
const eventType: string = event.type
|
||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
const moved = captureSessionMove(event, session.get)
|
||||||
|
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
|
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||||
|
if (shouldRefreshWorkspaceSessions(event)) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||||
|
})
|
||||||
|
}
|
||||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||||
homeSessions.apply(event)
|
homeSessions.apply(event)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(moved?.refresh ?? event.type)
|
||||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
@@ -572,10 +671,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.current?.type === "session.moved") {
|
|
||||||
const info = session.get(event.current.data.sessionID)
|
|
||||||
if (info) indexSession(info)
|
|
||||||
}
|
|
||||||
if (event.current?.type === "session.forked")
|
if (event.current?.type === "session.forked")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
@@ -682,12 +777,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
|
enableProviders: children.enableProviders,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
|
loadProviders: () => setProvidersEnabled(true),
|
||||||
refreshProviders,
|
refreshProviders,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
session,
|
session,
|
||||||
|
reindexSession,
|
||||||
homeSessions,
|
homeSessions,
|
||||||
mcp: {
|
mcp: {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Message, Part, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||||
|
|
||||||
|
test("decodes and projects a legacy message page", () => {
|
||||||
|
const info = {
|
||||||
|
id: "message",
|
||||||
|
sessionID: "session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
} as Message
|
||||||
|
const part = {
|
||||||
|
id: "part",
|
||||||
|
sessionID: "session",
|
||||||
|
messageID: info.id,
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
} as Part
|
||||||
|
const result = decodeLegacyMessagePage(new TextEncoder().encode(JSON.stringify([{ info, parts: [part] }])).buffer)
|
||||||
|
|
||||||
|
expect(result.session).toEqual([info])
|
||||||
|
expect(result.part).toEqual([{ id: info.id, part: [part] }])
|
||||||
|
expect(result.source).toEqual([{ id: info.id, type: "user", text: "hello", time: info.time }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("decodes and projects a legacy session list", () => {
|
||||||
|
const session = {
|
||||||
|
id: "session",
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/repo",
|
||||||
|
title: "Session",
|
||||||
|
version: "1",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
} as Session
|
||||||
|
const result = decodeLegacySessionList(new TextEncoder().encode(JSON.stringify([session])).buffer)
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("bounds Home sessions by directory before returning from the decoder", () => {
|
||||||
|
const session = (id: string, directory: string, updated: number) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
projectID: "project",
|
||||||
|
location: { directory },
|
||||||
|
subpath: "",
|
||||||
|
title: id,
|
||||||
|
time: { created: updated, updated },
|
||||||
|
}) as SessionV2Info
|
||||||
|
const page = {
|
||||||
|
data: [session("old", "/repo", 1), session("new", "/repo", 2), session("other", "/other", 3)],
|
||||||
|
cursor: {},
|
||||||
|
}
|
||||||
|
const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, {
|
||||||
|
directories: ["/repo"],
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data.map((item) => item.id)).toEqual(["new"])
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
import type { Message, Part, Session, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { message as cleanMessage } from "@/utils/diffs"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { parseHomeSessionIndex } from "./global-sync/home-session-index"
|
||||||
|
import { takeRecentSessions } from "./global-sync/session-trim"
|
||||||
|
|
||||||
|
export type DecodedLegacyMessagePage = {
|
||||||
|
session: Message[]
|
||||||
|
part: { id: string; part: Part[] }[]
|
||||||
|
source: SessionMessageInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLegacyMessagePage(buffer: ArrayBuffer): DecodedLegacyMessagePage {
|
||||||
|
const text = new TextDecoder().decode(buffer)
|
||||||
|
const items = (text ? (JSON.parse(text) as { info?: Message; parts?: Part[] }[]) : []).filter(
|
||||||
|
(item): item is { info: Message; parts: Part[] } => !!item.info?.id && Array.isArray(item.parts),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => compare(a.id, b.id)),
|
||||||
|
part: items.map((item) => ({
|
||||||
|
id: item.info.id,
|
||||||
|
part: item.parts.filter((part) => !!part?.id).sort((a, b) => compare(a.id, b.id)),
|
||||||
|
})),
|
||||||
|
source: items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => compare(a.info.id, b.info.id))
|
||||||
|
.map((item) =>
|
||||||
|
item.info.role === "user"
|
||||||
|
? {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "user" as const,
|
||||||
|
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
|
time: item.info.time,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "assistant" as const,
|
||||||
|
agent: item.info.agent ?? item.info.mode,
|
||||||
|
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||||
|
content: [],
|
||||||
|
time: item.info.time,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLegacySessionList(buffer: ArrayBuffer) {
|
||||||
|
const text = new TextDecoder().decode(buffer)
|
||||||
|
return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHomeSessionPage(buffer: ArrayBuffer, options?: { directories: string[]; limit: number }) {
|
||||||
|
const text = new TextDecoder().decode(buffer)
|
||||||
|
const page = (text ? JSON.parse(text) : { data: [], cursor: {} }) as V2SessionListResponse
|
||||||
|
const sessions = parseHomeSessionIndex(page.data)
|
||||||
|
if (!options) return { data: sessions, cursor: page.cursor }
|
||||||
|
const directories = new Set(options.directories.map(pathKey))
|
||||||
|
return {
|
||||||
|
data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))]
|
||||||
|
.filter(([directory]) => directories.has(directory))
|
||||||
|
.flatMap(([, items]) => takeRecentSessions(items, options.limit, Number.NEGATIVE_INFINITY)),
|
||||||
|
cursor: page.cursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacySessionInfo(session: Session): SessionInfo {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
parentID: session.parentID,
|
||||||
|
projectID: session.projectID,
|
||||||
|
agent: session.agent,
|
||||||
|
model: session.model && {
|
||||||
|
id: session.model.id,
|
||||||
|
providerID: session.model.providerID,
|
||||||
|
variant: session.model.variant,
|
||||||
|
},
|
||||||
|
cost: session.cost ?? 0,
|
||||||
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: session.time,
|
||||||
|
title: session.title,
|
||||||
|
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||||
|
subpath: session.path,
|
||||||
|
revert: session.revert && {
|
||||||
|
messageID: session.revert.messageID,
|
||||||
|
partID: session.revert.partID,
|
||||||
|
snapshot: session.revert.snapshot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(a: string, b: string) {
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type Response = { id: number; data?: unknown; error?: string }
|
||||||
|
|
||||||
|
let worker: Worker | undefined
|
||||||
|
let nextID = 0
|
||||||
|
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
||||||
|
|
||||||
|
export function decodeSessionMessages(buffer: ArrayBuffer) {
|
||||||
|
return decode<DecodedLegacyMessagePage>("messages", buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeSessionList(buffer: ArrayBuffer) {
|
||||||
|
return decode<SessionInfo[]>("sessions", buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHomeSessionPage(buffer: ArrayBuffer, options: { directories: string[]; limit: number }) {
|
||||||
|
return decode<{ data: Session[]; cursor: { next?: string } }>("homeSessions", buffer, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode<T>(
|
||||||
|
type: "messages" | "sessions" | "homeSessions",
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
options?: { directories: string[]; limit: number },
|
||||||
|
) {
|
||||||
|
const id = ++nextID
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve: (value) => resolve(value as T), reject })
|
||||||
|
getWorker().postMessage({ id, type, buffer, options }, [buffer])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorker() {
|
||||||
|
if (worker) return worker
|
||||||
|
worker = new Worker(new URL("./session-message-decoder.worker.ts", import.meta.url), { type: "module" })
|
||||||
|
worker.onmessage = (event: MessageEvent<Response>) => {
|
||||||
|
const request = pending.get(event.data.id)
|
||||||
|
if (!request) return
|
||||||
|
pending.delete(event.data.id)
|
||||||
|
if (event.data.error) {
|
||||||
|
request.reject(new Error(event.data.error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.resolve(event.data.data)
|
||||||
|
}
|
||||||
|
worker.onerror = (event) => {
|
||||||
|
const error = new Error(event.message)
|
||||||
|
pending.forEach((request) => request.reject(error))
|
||||||
|
pending.clear()
|
||||||
|
worker?.terminate()
|
||||||
|
worker = undefined
|
||||||
|
}
|
||||||
|
return worker
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||||
|
|
||||||
|
type DecoderRequest = {
|
||||||
|
id: number
|
||||||
|
type: "messages" | "sessions" | "homeSessions"
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
options?: { directories: string[]; limit: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<DecoderRequest>) => {
|
||||||
|
try {
|
||||||
|
self.postMessage({
|
||||||
|
id: event.data.id,
|
||||||
|
data: (() => {
|
||||||
|
if (event.data.type === "messages") return decodeLegacyMessagePage(event.data.buffer)
|
||||||
|
if (event.data.type === "sessions") return decodeLegacySessionList(event.data.buffer)
|
||||||
|
return decodeHomeSessionPage(event.data.buffer, event.data.options)
|
||||||
|
})(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -3,6 +3,10 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||||
|
export type WorkspaceLastUsed = "local" | "workspace"
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -50,6 +54,10 @@ export interface Settings {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: boolean
|
autoApprove: boolean
|
||||||
}
|
}
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: WorkspaceDefaultDestination
|
||||||
|
lastUsed: Record<string, WorkspaceLastUsed>
|
||||||
|
}
|
||||||
notifications: NotificationSettings
|
notifications: NotificationSettings
|
||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
}
|
}
|
||||||
@@ -206,6 +214,10 @@ const defaultSettings: Settings = {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: false,
|
autoApprove: false,
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: "last-used",
|
||||||
|
lastUsed: {},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: true,
|
agent: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
@@ -355,6 +367,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("general", "followup", "steer")
|
setStore("general", "followup", "steer")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return
|
||||||
|
setStore("general", "showFileTree", false)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
get current() {
|
get current() {
|
||||||
@@ -453,7 +470,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: visible(showFileTree),
|
fileTree: createMemo(() => !newLayoutDesigns()),
|
||||||
search: visible(showSearch),
|
search: visible(showSearch),
|
||||||
status: visible(showStatus),
|
status: visible(showStatus),
|
||||||
customAgents: visible(showCustomAgents),
|
customAgents: visible(showCustomAgents),
|
||||||
@@ -499,6 +516,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("permissions", "autoApprove", value)
|
setStore("permissions", "autoApprove", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: withFallback(
|
||||||
|
() => store.workspaces?.defaultDestination,
|
||||||
|
defaultSettings.workspaces.defaultDestination,
|
||||||
|
),
|
||||||
|
setDefaultDestination(value: WorkspaceDefaultDestination) {
|
||||||
|
setStore("workspaces", (current) => ({
|
||||||
|
...defaultSettings.workspaces,
|
||||||
|
...current,
|
||||||
|
defaultDestination: value,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
lastUsed(scope: ServerScope, projectID: string) {
|
||||||
|
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
|
||||||
|
},
|
||||||
|
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
|
||||||
|
setStore("workspaces", (current) => ({
|
||||||
|
...defaultSettings.workspaces,
|
||||||
|
...current,
|
||||||
|
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||||
setAgent(value: boolean) {
|
setAgent(value: boolean) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||||
|
|
||||||
|
function history(): TabHistory {
|
||||||
|
return { stack: [], index: -1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tab history", () => {
|
||||||
|
test("moves backward and forward through selected tabs", () => {
|
||||||
|
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||||
|
const available = new Set(selected.stack)
|
||||||
|
|
||||||
|
const previous = previousTab(selected, available)
|
||||||
|
expect(previous?.key).toBe("b")
|
||||||
|
|
||||||
|
const first = previousTab(previous!.state, available)
|
||||||
|
expect(first?.key).toBe("a")
|
||||||
|
|
||||||
|
const next = nextTab(first!.state, available)
|
||||||
|
expect(next?.key).toBe("b")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces forward history after a new selection", () => {
|
||||||
|
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||||
|
const previous = previousTab(selected, new Set(selected.stack))
|
||||||
|
const next = rememberTab(previous!.state, "d")
|
||||||
|
|
||||||
|
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||||
|
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips tabs that are no longer open", () => {
|
||||||
|
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||||
|
|
||||||
|
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips a repeated current tab after closing the previous selection", () => {
|
||||||
|
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||||
|
|
||||||
|
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const MAX_TAB_HISTORY = 100
|
||||||
|
|
||||||
|
export type TabHistory = {
|
||||||
|
stack: string[]
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||||
|
if (state.stack[state.index] === key) return state
|
||||||
|
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||||
|
return { stack, index: stack.length - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||||
|
return move(state, -1, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||||
|
return move(state, 1, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||||
|
const current = state.stack[state.index]
|
||||||
|
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||||
|
const key = state.stack[index]
|
||||||
|
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
|
|||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||||
import { migrateTabs } from "./tab-migration"
|
import { migrateTabs } from "./tab-migration"
|
||||||
|
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -74,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const memory = createTabMemory(getOwner())
|
const memory = createTabMemory(getOwner())
|
||||||
|
|
||||||
const closing = new Set<string>()
|
const closing = new Set<string>()
|
||||||
|
let history: TabHistory = { stack: [], index: -1 }
|
||||||
let recentWrite = 0
|
let recentWrite = 0
|
||||||
let recentValue: string | undefined
|
let recentValue: string | undefined
|
||||||
|
|
||||||
@@ -148,10 +150,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const navigateTab = (tab: Tab) => {
|
const navigateTab = (tab: Tab) => {
|
||||||
const href = tabHref(tab)
|
const href = tabHref(tab)
|
||||||
|
history = rememberTab(history, tabKey(tab))
|
||||||
setRecentKey(tabKey(tab))
|
setRecentKey(tabKey(tab))
|
||||||
navigate(href)
|
navigate(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moveHistory = (direction: "previous" | "next") => {
|
||||||
|
const available = new Set(store.map(tabKey))
|
||||||
|
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||||
|
if (!result) return
|
||||||
|
const tab = store.find((item) => tabKey(item) === result.key)
|
||||||
|
if (!tab) return
|
||||||
|
history = result.state
|
||||||
|
navigateTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
const removeTab = (index: number) => {
|
const removeTab = (index: number) => {
|
||||||
const tab = store[index]
|
const tab = store[index]
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
@@ -177,6 +190,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
active() {
|
||||||
|
if (location.pathname === "/") return
|
||||||
|
const key = recentKey()
|
||||||
|
return store.find((tab) => tabKey(tab) === key)
|
||||||
|
},
|
||||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||||
const next = { type: "session" as const, ...tab }
|
const next = { type: "session" as const, ...tab }
|
||||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||||
@@ -357,8 +375,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
select: navigateTab,
|
select: navigateTab,
|
||||||
remember(tab: Tab) {
|
remember(tab: Tab) {
|
||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
|
history = rememberTab(history, key)
|
||||||
if (recentKey() !== key) setRecentKey(key)
|
if (recentKey() !== key) setRecentKey(key)
|
||||||
},
|
},
|
||||||
|
previous: () => moveHistory("previous"),
|
||||||
|
next: () => moveHistory("next"),
|
||||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||||
if (input.home) {
|
if (input.home) {
|
||||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { DESKTOP_MENU } from "./desktop-menu"
|
import { DESKTOP_MENU } from "./desktop-menu"
|
||||||
|
|
||||||
describe("desktop menu", () => {
|
describe("desktop menu", () => {
|
||||||
|
test("navigates between tabs", () => {
|
||||||
|
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
|
(item) =>
|
||||||
|
item.type === "item" && (item.labelKey === "desktop.menu.previousTab" || item.labelKey === "desktop.menu.nextTab"),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(items).toEqual([
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
labelKey: "desktop.menu.previousTab",
|
||||||
|
command: "tab.prev",
|
||||||
|
accelerator: { macos: "Option+Up" },
|
||||||
|
},
|
||||||
|
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("exports logs through the desktop command registry", () => {
|
test("exports logs through the desktop command registry", () => {
|
||||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||||
{ type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
{ type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{ type: "item", labelKey: "desktop.menu.previousTab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||||
|
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||||
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
labelKey: "desktop.menu.previousSession",
|
labelKey: "desktop.menu.previousSession",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const dir = () => (directory ? directory() : decode64(params.dir))
|
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||||
|
createEffect(() => {
|
||||||
|
const value = dir()
|
||||||
|
if (value) {
|
||||||
|
serverSync().enableProviders(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||||
|
|||||||
@@ -1155,4 +1155,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
||||||
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
|
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
|
||||||
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
|
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,4 +1152,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.one": "1 sessiya arxivlənəcək.",
|
"workspace.reset.archived.one": "1 sessiya arxivlənəcək.",
|
||||||
"workspace.reset.archived.many": "{{count}} sessiya arxivlənəcək.",
|
"workspace.reset.archived.many": "{{count}} sessiya arxivlənəcək.",
|
||||||
"workspace.reset.note": "Bu iş sahəsini standart branch ilə uyğunlaşdırmaq üçün sıfırlayacaq.",
|
"workspace.reset.note": "Bu iş sahəsini standart branch ilə uyğunlaşdırmaq üçün sıfırlayacaq.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1165,4 +1165,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
||||||
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
|
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1241,4 +1241,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
||||||
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
|
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1232,4 +1232,23 @@ export const dict = {
|
|||||||
"help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.",
|
"help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.",
|
||||||
"help.tabs.worktrees":
|
"help.tabs.worktrees":
|
||||||
"Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.",
|
"Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1169,4 +1169,23 @@ export const dict = {
|
|||||||
"help.tabs.persistence": "Wenn Sie die App erneut öffnen, sind Ihre Tabs weiterhin geöffnet.",
|
"help.tabs.persistence": "Wenn Sie die App erneut öffnen, sind Ihre Tabs weiterhin geöffnet.",
|
||||||
"help.tabs.worktrees":
|
"help.tabs.worktrees":
|
||||||
"Das neue Design unterstützt Git-Worktrees noch nicht. Diese Funktion folgt in Kürze. Wenn Sie das bisherige Layout vorerst weiterverwenden möchten, können Sie in den Einstellungen zwischen den Layouts wechseln. Beachten Sie jedoch, dass das neue Layout in einigen Wochen dauerhaft verwendet wird.",
|
"Das neue Design unterstützt Git-Worktrees noch nicht. Diese Funktion folgt in Kürze. Wenn Sie das bisherige Layout vorerst weiterverwenden möchten, können Sie in den Einstellungen zwischen den Layouts wechseln. Beachten Sie jedoch, dass das neue Layout in einigen Wochen dauerhaft verwendet wird.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
} satisfies Partial<Record<Keys, string>>
|
} satisfies Partial<Record<Keys, string>>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
|||||||
"desktop.menu.toggleFullScreen": "Toggle Full Screen",
|
"desktop.menu.toggleFullScreen": "Toggle Full Screen",
|
||||||
"desktop.menu.back": "Back",
|
"desktop.menu.back": "Back",
|
||||||
"desktop.menu.forward": "Forward",
|
"desktop.menu.forward": "Forward",
|
||||||
|
"desktop.menu.previousTab": "Previous Tab",
|
||||||
|
"desktop.menu.nextTab": "Next Tab",
|
||||||
"desktop.menu.previousSession": "Previous Session",
|
"desktop.menu.previousSession": "Previous Session",
|
||||||
"desktop.menu.nextSession": "Next Session",
|
"desktop.menu.nextSession": "Next Session",
|
||||||
"desktop.menu.previousProject": "Previous Project",
|
"desktop.menu.previousProject": "Previous Project",
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const dict = {
|
|||||||
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
||||||
"command.session.unshare": "Unshare session",
|
"command.session.unshare": "Unshare session",
|
||||||
"command.session.unshare.description": "Stop sharing this session",
|
"command.session.unshare.description": "Stop sharing this session",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
|
||||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||||
"palette.search.placeholder.home": "Search commands and sessions",
|
"palette.search.placeholder.home": "Search commands and sessions",
|
||||||
@@ -489,6 +491,7 @@ export const dict = {
|
|||||||
|
|
||||||
"context.systemPrompt.title": "System Prompt",
|
"context.systemPrompt.title": "System Prompt",
|
||||||
"context.rawMessages.title": "Raw messages",
|
"context.rawMessages.title": "Raw messages",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
|
||||||
"context.stats.session": "Session",
|
"context.stats.session": "Session",
|
||||||
"context.stats.messages": "Messages",
|
"context.stats.messages": "Messages",
|
||||||
@@ -568,6 +571,11 @@ export const dict = {
|
|||||||
"toast.session.unshare.failed.title": "Failed to unshare session",
|
"toast.session.unshare.failed.title": "Failed to unshare session",
|
||||||
"toast.session.unshare.failed.description": "An error occurred while unsharing the session",
|
"toast.session.unshare.failed.description": "An error occurred while unsharing the session",
|
||||||
|
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
|
||||||
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||||
|
|
||||||
@@ -802,6 +810,7 @@ export const dict = {
|
|||||||
"common.moreOptions": "More options",
|
"common.moreOptions": "More options",
|
||||||
"common.learnMore": "Learn more",
|
"common.learnMore": "Learn more",
|
||||||
"common.rename": "Rename",
|
"common.rename": "Rename",
|
||||||
|
"common.export": "Export",
|
||||||
"common.reset": "Reset",
|
"common.reset": "Reset",
|
||||||
"common.archive": "Archive",
|
"common.archive": "Archive",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
@@ -882,7 +891,18 @@ export const dict = {
|
|||||||
"settings.section.desktop": "Desktop",
|
"settings.section.desktop": "Desktop",
|
||||||
"settings.section.server": "Server",
|
"settings.section.server": "Server",
|
||||||
"settings.tab.general": "General",
|
"settings.tab.general": "General",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
"settings.tab.shortcuts": "Shortcuts",
|
"settings.tab.shortcuts": "Shortcuts",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
"settings.desktop.section.wsl": "WSL",
|
"settings.desktop.section.wsl": "WSL",
|
||||||
"settings.desktop.wsl.title": "WSL integration",
|
"settings.desktop.wsl.title": "WSL integration",
|
||||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||||
@@ -1113,6 +1133,46 @@ export const dict = {
|
|||||||
"session.delete.button": "Delete session",
|
"session.delete.button": "Delete session",
|
||||||
|
|
||||||
"workspace.new": "New workspace",
|
"workspace.new": "New workspace",
|
||||||
|
"common.viewAll": "View all",
|
||||||
|
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||||
|
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||||
|
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||||
|
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||||
|
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||||
|
"settings.tab.workspaces": "Workspaces",
|
||||||
|
"settings.workspaces.filter.all": "All projects",
|
||||||
|
"settings.workspaces.empty": "No workspaces",
|
||||||
|
"settings.workspaces.count.one": "{{count}} workspace",
|
||||||
|
"settings.workspaces.count.other": "{{count}} workspaces",
|
||||||
|
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||||
|
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||||
|
"settings.workspaces.lastActiveSession": "Last active session",
|
||||||
|
"settings.workspaces.deleteAll": "Delete all workspaces",
|
||||||
|
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
|
||||||
|
"settings.workspaces.delete.warning":
|
||||||
|
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
|
||||||
|
"settings.workspaces.deleteAll.warning":
|
||||||
|
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||||
|
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
|
||||||
|
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
|
||||||
|
"settings.workspaces.default.title": "Default environment",
|
||||||
|
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||||
|
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||||
|
"settings.workspaces.default.local": "Local directory",
|
||||||
|
"settings.workspaces.default.new": "New workspace",
|
||||||
|
"workspace.move.title": "Move to workspace",
|
||||||
|
"workspace.move.menu.title": "Move session to",
|
||||||
|
"workspace.move.failed": "Failed to move session",
|
||||||
|
"workspace.lifecycle.creating": "Creating workspace",
|
||||||
|
"workspace.lifecycle.created": "Workspace created",
|
||||||
|
"workspace.lifecycle.starting": "Starting session",
|
||||||
|
"workspace.onboarding.title": "Isolate sessions with workspaces",
|
||||||
|
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||||
|
"workspace.lifecycle.moving": "Moving to workspace",
|
||||||
|
"workspace.lifecycle.set": "Workspace set",
|
||||||
|
"session.summary.title": "Session details",
|
||||||
|
"session.summary.noBranch": "No branch",
|
||||||
|
"session.summary.basedOn": "Based on {{branch}}",
|
||||||
"workspace.type.local": "local",
|
"workspace.type.local": "local",
|
||||||
"workspace.type.sandbox": "sandbox",
|
"workspace.type.sandbox": "sandbox",
|
||||||
"workspace.create.failed.title": "Failed to create workspace",
|
"workspace.create.failed.title": "Failed to create workspace",
|
||||||
|
|||||||
@@ -1249,4 +1249,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
|
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
|
||||||
"error.childStore.storeCreateFailed": "Error al crear almacén",
|
"error.childStore.storeCreateFailed": "Error al crear almacén",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,4 +1152,23 @@ export const dict = {
|
|||||||
"help.tabs.persistence": "Kun avaat sovelluksen uudelleen, välilehtesi ovat yhä avoinna.",
|
"help.tabs.persistence": "Kun avaat sovelluksen uudelleen, välilehtesi ovat yhä avoinna.",
|
||||||
"help.tabs.worktrees":
|
"help.tabs.worktrees":
|
||||||
"Uusi ulkoasu ei vielä tue Git-työpuita, mutta tuki on tulossa pian. Jos haluat jatkaa aiemman ulkoasun käyttöä, voit vaihtaa ulkoasua asetuksissa. Huomaa kuitenkin, että uudesta ulkoasusta tulee pysyvä muutaman viikon kuluttua.",
|
"Uusi ulkoasu ei vielä tue Git-työpuita, mutta tuki on tulossa pian. Jos haluat jatkaa aiemman ulkoasun käyttöä, voit vaihtaa ulkoasua asetuksissa. Huomaa kuitenkin, että uudesta ulkoasusta tulee pysyvä muutaman viikon kuluttua.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1183,4 +1183,23 @@ export const dict = {
|
|||||||
|
|
||||||
"titlebar.update": "Mettre à jour",
|
"titlebar.update": "Mettre à jour",
|
||||||
"titlebar.updateVersion": "Mettre à jour {{version}}",
|
"titlebar.updateVersion": "Mettre à jour {{version}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1147,4 +1147,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.one": "1 सेशन संग्रहित किया जाएगा।",
|
"workspace.reset.archived.one": "1 सेशन संग्रहित किया जाएगा।",
|
||||||
"workspace.reset.archived.many": "{{count}} सेशन संग्रहित किए जाएँगे।",
|
"workspace.reset.archived.many": "{{count}} सेशन संग्रहित किए जाएँगे।",
|
||||||
"workspace.reset.note": "यह डिफ़ॉल्ट शाखा से मिलान करने के लिए वर्कस्पेस को रीसेट कर देगा।",
|
"workspace.reset.note": "यह डिफ़ॉल्ट शाखा से मिलान करने के लिए वर्कस्पेस को रीसेट कर देगा।",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1234,4 +1234,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.one": "1 sesi akan diarsipkan.",
|
"workspace.reset.archived.one": "1 sesi akan diarsipkan.",
|
||||||
"workspace.reset.archived.many": "{{count}} sesi akan diarsipkan.",
|
"workspace.reset.archived.many": "{{count}} sesi akan diarsipkan.",
|
||||||
"workspace.reset.note": "Ini akan mengatur ulang ruang kerja agar cocok dengan cabang bawaan.",
|
"workspace.reset.note": "Ini akan mengatur ulang ruang kerja agar cocok dengan cabang bawaan.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,4 +1160,23 @@ export const dict = {
|
|||||||
"desktop.wsl.error.failedPort": "Impossibile ottenere la porta",
|
"desktop.wsl.error.failedPort": "Impossibile ottenere la porta",
|
||||||
"desktop.picker.error.notSelected": "Il file non è stato selezionato nella finestra di selezione",
|
"desktop.picker.error.notSelected": "Il file non è stato selezionato nella finestra di selezione",
|
||||||
"desktop.picker.error.sizeLimit": "Gli allegati selezionati superano il limite di {{limit}} MB",
|
"desktop.picker.error.sizeLimit": "Gli allegati selezionati superano il limite di {{limit}} MB",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1153,4 +1153,23 @@ export const dict = {
|
|||||||
|
|
||||||
"titlebar.update": "アップデート",
|
"titlebar.update": "アップデート",
|
||||||
"titlebar.updateVersion": "アップデート {{version}}",
|
"titlebar.updateVersion": "アップデート {{version}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1151,4 +1151,23 @@ export const dict = {
|
|||||||
|
|
||||||
"desktop.picker.error.notSelected": "선택기에서 파일을 선택하지 않았습니다",
|
"desktop.picker.error.notSelected": "선택기에서 파일을 선택하지 않았습니다",
|
||||||
"desktop.picker.error.sizeLimit": "선택한 첨부 파일이 {{limit}} MB 제한을 초과합니다",
|
"desktop.picker.error.sizeLimit": "선택한 첨부 파일이 {{limit}} MB 제한을 초과합니다",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1155,4 +1155,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.many": "{{count}} sessies worden gearchiveerd.",
|
"workspace.reset.archived.many": "{{count}} sessies worden gearchiveerd.",
|
||||||
"workspace.reset.note":
|
"workspace.reset.note":
|
||||||
"Hierdoor wordt de werkruimte opnieuw ingesteld zodat deze overeenkomt met de standaardbranch.",
|
"Hierdoor wordt de werkruimte opnieuw ingesteld zodat deze overeenkomt met de standaardbranch.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1251,4 +1251,23 @@ export const dict = {
|
|||||||
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
|
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
|
||||||
"settings.updates.action.downloading": "Laster ned...",
|
"settings.updates.action.downloading": "Laster ned...",
|
||||||
"settings.updates.action.installing": "Installerer...",
|
"settings.updates.action.installing": "Installerer...",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
} satisfies Partial<Record<Keys, string>>
|
} satisfies Partial<Record<Keys, string>>
|
||||||
|
|||||||
@@ -1145,4 +1145,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.one": "1 سیشن آرکائیو کیتا جائے گا۔",
|
"workspace.reset.archived.one": "1 سیشن آرکائیو کیتا جائے گا۔",
|
||||||
"workspace.reset.archived.many": "{{count}} سیشن آرکائیو کیتے جان گے۔",
|
"workspace.reset.archived.many": "{{count}} سیشن آرکائیو کیتے جان گے۔",
|
||||||
"workspace.reset.note": "ایہ ورک اسپیس نو ڈیفالٹ برانچ نال ملاون لئی ری سیٹ کرے گا۔",
|
"workspace.reset.note": "ایہ ورک اسپیس نو ڈیفالٹ برانچ نال ملاون لئی ری سیٹ کرے گا۔",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,17 +30,7 @@ const appLocales = [
|
|||||||
"sv",
|
"sv",
|
||||||
] as const
|
] as const
|
||||||
const desktopLocales = appLocales
|
const desktopLocales = appLocales
|
||||||
const pluralCategories: Partial<Record<(typeof appLocales)[number], readonly string[]>> = {
|
const pluralCategories = new Set(["zero", "one", "two", "few", "many", "other"])
|
||||||
ar: ["zero", "two", "few", "many"],
|
|
||||||
br: ["many"],
|
|
||||||
bs: ["few"],
|
|
||||||
es: ["many"],
|
|
||||||
fr: ["many"],
|
|
||||||
it: ["many"],
|
|
||||||
pl: ["few", "many"],
|
|
||||||
ru: ["few", "many"],
|
|
||||||
uk: ["few", "many"],
|
|
||||||
}
|
|
||||||
|
|
||||||
const domains = [
|
const domains = [
|
||||||
{
|
{
|
||||||
@@ -63,24 +53,20 @@ const domains = [
|
|||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
describe.skipIf(!!process.env.CI)("i18n parity", () => {
|
describe("i18n parity", () => {
|
||||||
test("non-English locales have every English key and required plural variants", async () => {
|
test("non-English locales contain only English keys and their plural variants", async () => {
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const source = await dictionary(domain.source)
|
const source = await dictionary(domain.source)
|
||||||
|
const families = new Set(pluralFamilies(source))
|
||||||
for (const locale of domain.locales) {
|
for (const locale of domain.locales) {
|
||||||
const target = await dictionary(domain.target(locale))
|
const target = await dictionary(domain.target(locale))
|
||||||
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
|
|
||||||
const extra = Object.keys(target)
|
const extra = Object.keys(target)
|
||||||
.filter((key) => !Object.hasOwn(source, key))
|
.filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families))
|
||||||
.sort()
|
.sort()
|
||||||
const expected = pluralFamilies(source)
|
expect({ domain: domain.name, locale, extra }).toEqual({
|
||||||
.flatMap((key) => (pluralCategories[locale] ?? []).map((category) => `${key}.${category}`))
|
|
||||||
.sort()
|
|
||||||
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
|
||||||
domain: domain.name,
|
domain: domain.name,
|
||||||
locale,
|
locale,
|
||||||
missing: [],
|
extra: [],
|
||||||
extra: expected,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,11 +80,11 @@ describe.skipIf(!!process.env.CI)("i18n parity", () => {
|
|||||||
const mismatched = Object.keys(source).filter(
|
const mismatched = Object.keys(source).filter(
|
||||||
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||||
)
|
)
|
||||||
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
const pluralMismatched = Object.keys(target).filter((key) => {
|
||||||
(pluralCategories[locale] ?? [])
|
const family = pluralFamily(key)
|
||||||
.map((category) => `${key}.${category}`)
|
if (!family || !Object.hasOwn(source, `${family}.other`)) return false
|
||||||
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join()
|
||||||
)
|
})
|
||||||
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
||||||
domain: domain.name,
|
domain: domain.name,
|
||||||
locale,
|
locale,
|
||||||
@@ -136,38 +122,6 @@ describe.skipIf(!!process.env.CI)("i18n parity", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("i18n plural parity", () => {
|
|
||||||
test("locale-specific categories exist and preserve count placeholders", async () => {
|
|
||||||
for (const domain of domains.slice(0, 2)) {
|
|
||||||
const source = await dictionary(domain.source)
|
|
||||||
const families = pluralFamilies(source)
|
|
||||||
for (const locale of domain.locales) {
|
|
||||||
const target = await dictionary(domain.target(locale))
|
|
||||||
const missing = families.flatMap((key) =>
|
|
||||||
(pluralCategories[locale] ?? [])
|
|
||||||
.map((category) => `${key}.${category}`)
|
|
||||||
.filter((variant) => !Object.hasOwn(target, variant)),
|
|
||||||
)
|
|
||||||
const mismatched = families.flatMap((key) =>
|
|
||||||
(pluralCategories[locale] ?? [])
|
|
||||||
.map((category) => `${key}.${category}`)
|
|
||||||
.filter(
|
|
||||||
(variant) =>
|
|
||||||
Object.hasOwn(target, variant) &&
|
|
||||||
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
|
|
||||||
domain: domain.name,
|
|
||||||
locale,
|
|
||||||
missing: [],
|
|
||||||
mismatched: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function dictionary(file: string) {
|
async function dictionary(file: string) {
|
||||||
const module: unknown = await import(file)
|
const module: unknown = await import(file)
|
||||||
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
||||||
@@ -187,11 +141,17 @@ function placeholders(value: string) {
|
|||||||
|
|
||||||
function pluralFamilies(dictionary: Record<string, string>) {
|
function pluralFamilies(dictionary: Record<string, string>) {
|
||||||
return Object.keys(dictionary)
|
return Object.keys(dictionary)
|
||||||
.filter(
|
.filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`))
|
||||||
(key) =>
|
|
||||||
key.endsWith(".one") &&
|
|
||||||
dictionary[key].includes("{{count}}") &&
|
|
||||||
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
|
||||||
)
|
|
||||||
.map((key) => key.slice(0, -4))
|
.map((key) => key.slice(0, -4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pluralFamily(key: string) {
|
||||||
|
const split = key.lastIndexOf(".")
|
||||||
|
if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return
|
||||||
|
return key.slice(0, split)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPluralVariant(key: string, families: Set<string>) {
|
||||||
|
const family = pluralFamily(key)
|
||||||
|
return family !== undefined && families.has(family)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1168,4 +1168,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
|
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
|
||||||
"error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu",
|
"error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1246,4 +1246,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
|
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
|
||||||
"error.childStore.storeCreateFailed": "Не удалось создать хранилище",
|
"error.childStore.storeCreateFailed": "Не удалось создать хранилище",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1144,4 +1144,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.one": "1 session kommer att arkiveras.",
|
"workspace.reset.archived.one": "1 session kommer att arkiveras.",
|
||||||
"workspace.reset.archived.many": "{{count}} sessioner kommer att arkiveras.",
|
"workspace.reset.archived.many": "{{count}} sessioner kommer att arkiveras.",
|
||||||
"workspace.reset.note": "Detta kommer att återställa arbetsytan så att den matchar standardgrenen.",
|
"workspace.reset.note": "Detta kommer att återställa arbetsytan så att den matchar standardgrenen.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1222,4 +1222,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
|
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
|
||||||
"error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ",
|
"error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1244,4 +1244,23 @@ export const dict = {
|
|||||||
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
|
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
|
||||||
"error.childStore.storeCreateFailed": "Depo oluşturulamadı",
|
"error.childStore.storeCreateFailed": "Depo oluşturulamadı",
|
||||||
"terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}",
|
"terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
} satisfies Partial<Record<Keys, string>>
|
} satisfies Partial<Record<Keys, string>>
|
||||||
|
|||||||
@@ -1251,4 +1251,23 @@ export const dict = {
|
|||||||
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
|
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
|
||||||
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
|
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
|
||||||
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
|
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
|
||||||
|
"command.session.export": "Export session",
|
||||||
|
"command.session.export.description": "Export the full session transcript as JSON",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
"toast.session.export.success.title": "Session exported",
|
||||||
|
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||||
|
"toast.session.export.failed.title": "Failed to export session",
|
||||||
|
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||||
|
"common.export": "Export",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.server.all": "All servers",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "View and configure projects on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user