mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 09:26:26 +00:00
Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da532a147a | ||
|
|
44513bd0b0 | ||
|
|
dc46ecfc55 | ||
|
|
f16acb1c95 | ||
|
|
d0d3504780 | ||
|
|
5e3100a46a | ||
|
|
69b30acfd4 | ||
|
|
ecb3c88601 | ||
|
|
a5ebf5bf2f | ||
|
|
f334377c5b | ||
|
|
873d437c12 | ||
|
|
13286e883e | ||
|
|
0d70595338 | ||
|
|
a02415a3eb | ||
|
|
beca5e8926 | ||
|
|
e3c89654d1 | ||
|
|
33f48f36c9 | ||
|
|
58f949d2d0 | ||
|
|
2a895b9e03 | ||
|
|
fe506f201d | ||
|
|
b2cecc6350 | ||
|
|
63a1074c6c | ||
|
|
31ee07e3ae | ||
|
|
370b9965d3 | ||
|
|
8ea99ef9ad | ||
|
|
e64b2bc137 | ||
|
|
cf1923c238 | ||
|
|
bfcb388dd7 | ||
|
|
ec46439ef5 | ||
|
|
1be3b32a47 | ||
|
|
2823b886d7 | ||
|
|
99651b2d50 | ||
|
|
33ef66746b | ||
|
|
cf212a4235 | ||
|
|
768e3d45a2 | ||
|
|
0674ddf043 | ||
|
|
41b66bc560 | ||
|
|
49a5baa059 | ||
|
|
1cf576ab55 | ||
|
|
f7d0f69e6c | ||
|
|
a66e6cd55e | ||
|
|
93b3725b14 | ||
|
|
371e344ba6 | ||
|
|
6559dcb01b | ||
|
|
35dbdfe287 | ||
|
|
cd504dc66a | ||
|
|
c4f2926ffb | ||
|
|
aa48433fa0 | ||
|
|
8da7526616 | ||
|
|
7a4ad68af6 | ||
|
|
2960c61f9c | ||
|
|
7ad705225e | ||
|
|
5045b3ce18 | ||
|
|
4306c07b34 | ||
|
|
7de1e86b5d | ||
|
|
a6fd1f8b22 | ||
|
|
52685d4517 | ||
|
|
30d1049942 | ||
|
|
21adcb4969 | ||
|
|
c72b535dee | ||
|
|
8d1a9799f4 | ||
|
|
86ba09c6e0 | ||
|
|
41cb354c3e | ||
|
|
23f3f8b6ca | ||
|
|
baab05727d | ||
|
|
541937d124 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.1
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"name": "@opencode-ai/ai",
|
||||
"version": "1.17.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -338,7 +339,6 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -350,7 +350,6 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
@@ -433,6 +432,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
@@ -1037,12 +1037,9 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
@@ -1067,7 +1064,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -1189,7 +1186,7 @@
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
@@ -3487,7 +3484,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -5867,6 +5864,8 @@
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
@@ -5875,8 +5874,6 @@
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
@@ -5923,9 +5920,9 @@
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -6385,8 +6382,6 @@
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
@@ -7247,10 +7242,6 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -235,6 +235,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ENTERPRISE_SALES_INBOX_EMAIL,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
|
||||
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
|
||||
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
|
||||
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
|
||||
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
|
||||
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
|
||||
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
|
||||
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -5,9 +5,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
@@ -146,10 +146,6 @@
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
@@ -159,7 +155,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
) => {
|
||||
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
@@ -431,7 +428,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
|
||||
@@ -639,6 +639,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
: state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(
|
||||
state.route,
|
||||
`Gemini stopped with ${candidate.finishReason}`,
|
||||
ProviderShared.encodeJson(event),
|
||||
),
|
||||
)
|
||||
if (!candidate?.content)
|
||||
return Effect.succeed([
|
||||
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReasonDetails,
|
||||
type LLMRequest,
|
||||
@@ -700,6 +702,16 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
|
||||
normalized: mapFinishReason(choice.finish_reason),
|
||||
raw: choice.finish_reason,
|
||||
}
|
||||
if (finishReason.normalized === "error") {
|
||||
const details = {
|
||||
message: `Mistral Chat stopped with ${finishReason.raw}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}
|
||||
return yield* new AIError({
|
||||
reason:
|
||||
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
|
||||
})
|
||||
}
|
||||
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
|
||||
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(
|
||||
|
||||
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "../../route/auth.js"
|
||||
import { AIError, AuthenticationError } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
/**
|
||||
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
|
||||
* which provider facades configure as route auth instead of SigV4. STS-vended
|
||||
* credentials should be refreshed by the consumer (rebuild the model) before
|
||||
* they expire; the route does not refresh.
|
||||
* which provider facades configure as route auth instead of SigV4.
|
||||
*/
|
||||
export interface Credentials {
|
||||
readonly region: string
|
||||
@@ -17,6 +16,44 @@ export interface Credentials {
|
||||
readonly sessionToken?: string
|
||||
}
|
||||
|
||||
/** Static credentials or an effect resolved before every request. */
|
||||
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
|
||||
|
||||
export interface DefaultChainOptions {
|
||||
readonly region: string
|
||||
/** Shared config profile passed to the AWS default chain. */
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials through the AWS default provider chain: environment
|
||||
* variables, shared config and SSO caches, web identity tokens, process
|
||||
* credentials, and container or instance metadata. A fresh chain runs on every
|
||||
* request so credentials rotated on disk without an expiration (for example
|
||||
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
|
||||
* the SDK's own memoization would otherwise pin them for the process lifetime.
|
||||
*/
|
||||
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
|
||||
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
|
||||
return {
|
||||
region: options.region,
|
||||
accessKeyId: identity.accessKeyId,
|
||||
secretAccessKey: identity.secretAccessKey,
|
||||
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
|
||||
}
|
||||
},
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new AuthenticationError({
|
||||
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const signRequest = (input: {
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
@@ -48,16 +85,17 @@ const signRequest = (input: {
|
||||
|
||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
source: CredentialSource | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
return Effect.gen(function* () {
|
||||
if (!credentials) {
|
||||
if (!source) {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
)
|
||||
}
|
||||
const credentials = Effect.isEffect(source) ? yield* source : source
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
const signed = yield* signRequest({
|
||||
url: input.url,
|
||||
@@ -74,4 +112,35 @@ export const sigV4 = (
|
||||
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
|
||||
export const auth = sigV4(undefined)
|
||||
|
||||
export const resolveRegion = (input: {
|
||||
readonly region?: string
|
||||
readonly credentials?: { readonly region: string }
|
||||
}) =>
|
||||
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
|
||||
|
||||
export interface ResolveAuthInput {
|
||||
readonly apiKey?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
}
|
||||
|
||||
export interface ResolveAuthOptions {
|
||||
readonly service?: string
|
||||
readonly name?: string
|
||||
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
|
||||
readonly mode?: "bearer" | "sigv4"
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer wins over SigV4 and explicit static credentials win over the default
|
||||
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
|
||||
* The region is applied to the SigV4 scope so it always matches the endpoint host.
|
||||
*/
|
||||
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
|
||||
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
|
||||
if (apiKey !== undefined) return Auth.bearer(apiKey)
|
||||
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
|
||||
return sigV4(defaultChain({ region, profile: input.profile }), options)
|
||||
}
|
||||
|
||||
export * as BedrockAuth from "./bedrock-auth.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint } from "../../schema/index.js"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
|
||||
import { newBreakpoints, ttlBucket } from "./cache.js"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
// after the content the caller wants treated as a cacheable prefix. Bedrock
|
||||
@@ -13,24 +13,46 @@ export const CachePointBlock = Schema.Struct({
|
||||
})
|
||||
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
|
||||
|
||||
// Callers pass a shared counter through every `block()` call site so the
|
||||
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
|
||||
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
|
||||
|
||||
// These legacy Claude releases support explicit caching, but only for five minutes.
|
||||
const CLAUDE_5M = [
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
]
|
||||
|
||||
// Callers share the four-breakpoint budget across system, messages, and tools.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export type { Breakpoints } from "./cache.js"
|
||||
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
|
||||
export const breakpoints = (modelID: string) => {
|
||||
// Substring matching also handles regional prefixes and model-bearing ARNs.
|
||||
const short = CLAUDE_5M.some((id) => modelID.includes(id))
|
||||
return {
|
||||
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
|
||||
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
|
||||
// Other model families use implicit caching where available.
|
||||
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
|
||||
ttl1h: !short,
|
||||
}
|
||||
}
|
||||
export type Breakpoints = ReturnType<typeof breakpoints>
|
||||
|
||||
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
|
||||
|
||||
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
if (!breakpoints.supported) return undefined
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
}
|
||||
|
||||
export * as BedrockCache from "./bedrock-cache.js"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: Credentials
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||
const region = BedrockAuth.resolveRegion(input)
|
||||
return route.with({
|
||||
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||
auth:
|
||||
input.apiKey === undefined
|
||||
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
|
||||
: Auth.bearer(input.apiKey),
|
||||
auth: BedrockAuth.resolveAuth(input, region, {
|
||||
service: "bedrock-mantle",
|
||||
name: "Bedrock Mantle",
|
||||
mode: input.auth,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
|
||||
const {
|
||||
apiKey: _,
|
||||
auth: _auth,
|
||||
baseURL: _baseURL,
|
||||
credentials: _credentials,
|
||||
profile: _profile,
|
||||
region: _region,
|
||||
...rest
|
||||
} = input
|
||||
return rest
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
const fromSettings = (settings: Settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
) => fromSettings(settings).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
) => fromSettings(settings).responses(modelID)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly headers?: Record<string, string>
|
||||
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
|
||||
readonly credentials?: BedrockCredentials
|
||||
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
|
||||
readonly region?: string
|
||||
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
|
||||
readonly baseURL?: string
|
||||
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: BedrockCredentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
|
||||
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
/**
|
||||
* Run an effect with `process.env` overrides, restoring the previous values
|
||||
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
|
||||
* credential providers, where Effect `ConfigProvider` layers do not apply.
|
||||
* `undefined` removes a variable for the duration of the effect.
|
||||
*/
|
||||
export const withProcessEnv =
|
||||
(env: Record<string, string | undefined>) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
|
||||
apply(env)
|
||||
return previous
|
||||
}),
|
||||
() => effect,
|
||||
(previous) => Effect.sync(() => apply(previous)),
|
||||
)
|
||||
|
||||
const apply = (env: Record<string, string | undefined>) => {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
continue
|
||||
}
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
|
||||
|
||||
describe("Bedrock Converse cache policy", () => {
|
||||
for (const id of [
|
||||
"deepseek.r1-v1:0",
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"mistral.mistral-large-2402-v1:0",
|
||||
"qwen.qwen3-coder-480b-a35b-v1:0",
|
||||
"openai.gpt-oss-120b-1:0",
|
||||
"cohere.command-r-v1:0",
|
||||
"anthropic.claude-instant-v1",
|
||||
"anthropic.claude-v1",
|
||||
"anthropic.claude-v2",
|
||||
"anthropic.claude-v2:1",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"anthropic.claude-3-opus-20240229-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"global.amazon.nova-2-lite-v1:0",
|
||||
"custom-model",
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
|
||||
]) {
|
||||
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
|
||||
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
|
||||
Effect.gen(function* () {
|
||||
// Exercise both automatic placement and manual hints at every lowering site.
|
||||
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model(id),
|
||||
cache: policy,
|
||||
system: [{ type: "text", text: "System prefix", cache }],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
|
||||
messages: [
|
||||
Message.user([{ type: "text", text: "Question", cache }]),
|
||||
Message.system([{ type: "text", text: "Update", cache }]),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Answer", cache },
|
||||
{ type: "reasoning", text: "Unsigned reasoning", cache },
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: id,
|
||||
system: [{ text: "System prefix" }],
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
|
||||
},
|
||||
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, ttl] of [
|
||||
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
|
||||
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
|
||||
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
|
||||
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
|
||||
["anthropic.claude-opus-4-20250514-v1:0", undefined],
|
||||
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
|
||||
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
|
||||
["global.anthropic.claude-sonnet-99", "1h"],
|
||||
["anthropic.claude-new-family-99", "1h"],
|
||||
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
|
||||
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
|
||||
] as const) {
|
||||
for (const ttlSeconds of [undefined, 3600]) {
|
||||
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model(id),
|
||||
system: [
|
||||
{ type: "text", text: "Agent" },
|
||||
{ type: "text", text: "Project" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
|
||||
prompt: "Question",
|
||||
cache:
|
||||
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
|
||||
}),
|
||||
)
|
||||
const marker = {
|
||||
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
|
||||
}
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: id,
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
|
||||
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
|
||||
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
|
||||
})
|
||||
if (ttlSeconds === undefined || ttl === undefined)
|
||||
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
type LanguageModel,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import {
|
||||
eventSummary,
|
||||
expectWeatherToolLoop,
|
||||
@@ -95,10 +98,45 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
|
||||
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
|
||||
)
|
||||
|
||||
// Clears every input the AWS default chain reads so tests do not pick up the
|
||||
// developer's profiles, SSO cache, or instance metadata.
|
||||
const noAmbientAWS = {
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_SECRET_ACCESS_KEY: undefined,
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_REGION: undefined,
|
||||
AWS_DEFAULT_REGION: undefined,
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
|
||||
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
}
|
||||
|
||||
const captureHeaders = (target: LanguageModel) =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<Headers> = []
|
||||
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers)
|
||||
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return seen[0]!
|
||||
})
|
||||
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
|
||||
const baseRequest = LLM.request({
|
||||
id: "req_1",
|
||||
@@ -117,7 +155,7 @@ describe("Bedrock Converse route", () => {
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
system: [{ text: "You are concise." }],
|
||||
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
|
||||
inferenceConfig: { maxTokens: 64, temperature: 0 },
|
||||
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects requests with no auth path", () =>
|
||||
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const unsignedModel = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
profile: "opencode-test-missing-profile",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
|
||||
}),
|
||||
expect(error.reason._tag).toBe("Authentication")
|
||||
expect(error.message).toContain("AWS default credential chain failed")
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
|
||||
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const signed = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
region: "eu-west-1",
|
||||
credentials: {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
|
||||
const headers = yield* captureHeaders(signed)
|
||||
|
||||
expect(prepared.route).toBe("bedrock-converse")
|
||||
expect(prepared.model).toBe(signed)
|
||||
}),
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBeNull()
|
||||
}).pipe(withProcessEnv(noAmbientAWS)),
|
||||
)
|
||||
|
||||
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(chained)
|
||||
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
|
||||
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: "chain-session-token",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const first = yield* captureHeaders(chained)
|
||||
const second = yield* captureHeaders(chained).pipe(
|
||||
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
|
||||
)
|
||||
|
||||
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
|
||||
Effect.gen(function* () {
|
||||
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const headers = yield* captureHeaders(bearer)
|
||||
|
||||
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
)
|
||||
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
|
||||
auth: "sigv4",
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
})
|
||||
|
||||
for (const target of [configured, fromSettings]) {
|
||||
const headers = yield* captureHeaders(target)
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
|
||||
AWS_REGION: "ap-southeast-2",
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
|
||||
Effect.gen(function* () {
|
||||
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
|
||||
|
||||
const fromDefault = yield* Effect.sync(() =>
|
||||
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
|
||||
).pipe(withProcessEnv({ AWS_REGION: undefined }))
|
||||
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
|
||||
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
|
||||
)
|
||||
|
||||
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("signs with the Mantle service using default-chain credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<string | undefined> = []
|
||||
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push(request.headers.get("authorization") ?? undefined)
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
AWS_BEARER_TOKEN_BEDROCK: undefined,
|
||||
AWS_PROFILE: undefined,
|
||||
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
|
||||
AWS_SECRET_ACCESS_KEY: "chain-secret",
|
||||
AWS_SESSION_TOKEN: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("supports bearer authentication and custom base URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
|
||||
@@ -1643,19 +1643,14 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["MODEL_ARMOR", "content-filter"],
|
||||
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
|
||||
["IMAGE_RECITATION", "content-filter"],
|
||||
["LANGUAGE", "content-filter"],
|
||||
["UNEXPECTED_TOOL_CALL", "error"],
|
||||
["NO_IMAGE", "error"],
|
||||
["IMAGE_OTHER", "unknown"],
|
||||
["TOO_MANY_TOOL_CALLS", "error"],
|
||||
["MISSING_THOUGHT_SIGNATURE", "error"],
|
||||
["MALFORMED_RESPONSE", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
@@ -1666,6 +1661,26 @@ describe("Gemini route", () => {
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
|
||||
for (const raw of [
|
||||
"MALFORMED_FUNCTION_CALL",
|
||||
"UNEXPECTED_TOOL_CALL",
|
||||
"NO_IMAGE",
|
||||
"TOO_MANY_TOOL_CALLS",
|
||||
"MISSING_THOUGHT_SIGNATURE",
|
||||
"MALFORMED_RESPONSE",
|
||||
]) {
|
||||
const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" }
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "AI.Error",
|
||||
reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } },
|
||||
message: `Gemini stopped with ${raw}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -600,7 +600,6 @@ describe("Mistral Chat", () => {
|
||||
["stop", "stop"],
|
||||
["model_length", "length"],
|
||||
["tool_calls", "tool-calls"],
|
||||
["error", "error"],
|
||||
["future_reason", "unknown"],
|
||||
] as const) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -609,6 +608,22 @@ describe("Mistral Chat", () => {
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
|
||||
for (const [raw, tag] of [
|
||||
["error", "UnknownProvider"],
|
||||
["network_error", "ProviderInternal"],
|
||||
] as const) {
|
||||
const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } }
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "AI.Error",
|
||||
reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } },
|
||||
message: `Mistral Chat stopped with ${raw}`,
|
||||
})
|
||||
}
|
||||
|
||||
const truncated = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
|
||||
@@ -1,16 +1,139 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
for (const alternate of ["none", "queue", "steer"]) {
|
||||
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--toolbar-overflow", {
|
||||
args: { alternate },
|
||||
globals: { direction },
|
||||
})
|
||||
const controls = component.locator('[data-slot="composer-controls"]')
|
||||
const actions = component.locator('[data-slot="composer-actions"]')
|
||||
const submit = component.locator('[data-action="composer-submit"]')
|
||||
const add = component.locator('[data-action="composer-attach"]')
|
||||
const agent = controls.getByRole("button", { name: "Choose agent" })
|
||||
const variant = controls.getByRole("button", { name: "Choose model variant" })
|
||||
await expect(controls).toHaveCSS("overflow-x", "auto")
|
||||
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
|
||||
await expect(controls).toHaveCSS("direction", direction)
|
||||
await expect(controls).toHaveCSS("padding-inline-start", "0px")
|
||||
await expect(controls).toHaveCSS("padding-inline-end", "0px")
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
|
||||
alternate === "none" ? 0 : 1,
|
||||
)
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
for (const width of [1024, 360]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await expect
|
||||
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeGreaterThan(0)
|
||||
const fixed = await submit.boundingBox()
|
||||
const fixedAdd = await add.boundingBox()
|
||||
const viewport = await controls.boundingBox()
|
||||
const action = await actions.boundingBox()
|
||||
expect(fixed).not.toBeNull()
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(action).not.toBeNull()
|
||||
expect(fixedAdd).not.toBeNull()
|
||||
if (!fixed || !viewport || !action || !fixedAdd) return
|
||||
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
|
||||
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
|
||||
1,
|
||||
)
|
||||
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
|
||||
direction === "ltr" ? action.x - 12 : viewport.x - 12,
|
||||
1,
|
||||
)
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft = 0
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "true")
|
||||
await expect(controls).toHaveCSS(
|
||||
"mask-image",
|
||||
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
|
||||
)
|
||||
const first = await agent.boundingBox()
|
||||
if (!first) throw new Error("Missing agent control")
|
||||
expect(
|
||||
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
|
||||
).toBeCloseTo(0, 0)
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft =
|
||||
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "true")
|
||||
const scrolled = (await controls.boundingBox())!
|
||||
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
|
||||
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
|
||||
await controls.hover()
|
||||
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
|
||||
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
|
||||
expect(await submit.boundingBox()).toEqual(fixed)
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
|
||||
// The fade disappears at the endpoint instead of reserving padding.
|
||||
await variant.focus()
|
||||
await controls.evaluate((element) => {
|
||||
element.scrollLeft =
|
||||
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
|
||||
})
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "false")
|
||||
const last = await variant.boundingBox()
|
||||
if (!last) throw new Error("Missing variant control")
|
||||
expect(
|
||||
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
|
||||
).toBeLessThan(1)
|
||||
expect(
|
||||
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
|
||||
).toBeLessThan(1)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(variant).toBeFocused()
|
||||
await expect(submit).toBeInViewport()
|
||||
await expect(add).toBeInViewport()
|
||||
await add.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
expect(await add.boundingBox()).toEqual(fixedAdd)
|
||||
}
|
||||
|
||||
if (alternate !== "none") {
|
||||
const width = (await controls.boundingBox())!.width
|
||||
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
|
||||
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
|
||||
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("does not mask or pad controls when they fit", async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const controls = component.locator('[data-slot="composer-controls"]')
|
||||
await expect(controls).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(controls).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(controls).toHaveCSS("mask-image", "none")
|
||||
await expect(controls).toHaveCSS("padding-inline-start", "0px")
|
||||
await expect(controls).toHaveCSS("padding-inline-end", "0px")
|
||||
})
|
||||
|
||||
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
|
||||
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
|
||||
for (const [theme, background] of [
|
||||
["light", "rgb(255, 255, 255)"],
|
||||
["dark", "rgb(36, 36, 36)"],
|
||||
] as const) {
|
||||
story(`raises the docked composer only in dark mode (${theme})`, async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft", { globals: { theme } })
|
||||
await expect(component.locator('[data-component="composer"]')).toHaveCSS("background-color", background)
|
||||
})
|
||||
}
|
||||
|
||||
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
await component.locator('[data-action="composer-attach"]').click()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
story(`shimmers once over the visible wordmark (${theme}, ${direction})`, async ({ mount, page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme, direction } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
|
||||
const shimmer = logo.locator(".wordmark-shimmer")
|
||||
const base = logo.locator("svg").first()
|
||||
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
|
||||
await expect(logo.locator("g[mask]")).toHaveCount(0)
|
||||
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
|
||||
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
|
||||
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = 0
|
||||
})
|
||||
})
|
||||
await expect(reveal).toHaveCSS("opacity", "1")
|
||||
await expect(reveal).toHaveCSS("animation-name", "none")
|
||||
await expect(shimmer).toHaveCSS("mask-position", "100% 0px")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => (animation.currentTime = 400))
|
||||
})
|
||||
await expect(shimmer).toHaveCSS("mask-position", "50% 0px")
|
||||
await expect(shimmer).toHaveCSS("opacity", theme === "dark" ? "0.0672" : "0.45")
|
||||
const swept = await logo.screenshot({ path: testInfo.outputPath("wordmark-shimmer.png") })
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())
|
||||
})
|
||||
await expect(reveal).toHaveCSS("opacity", "1")
|
||||
await expect(shimmer).toHaveCSS("opacity", "0")
|
||||
await expect(shimmer).toHaveCSS("mask-position", "0% 0px")
|
||||
const settled = await logo.screenshot({ path: testInfo.outputPath("wordmark-settled.png") })
|
||||
const brightness = await page.evaluate(
|
||||
async (screenshots) => {
|
||||
const pixels = await Promise.all(
|
||||
screenshots.map(async (screenshot) => {
|
||||
const image = new Image()
|
||||
image.src = `data:image/png;base64,${screenshot}`
|
||||
await image.decode()
|
||||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("Cannot read screenshot pixels")
|
||||
context.drawImage(image, 0, 0)
|
||||
return context.getImageData(0, 0, image.width, image.height).data
|
||||
}),
|
||||
)
|
||||
return {
|
||||
min: pixels[0].reduce(
|
||||
(min, value, index) => (index % 4 === 3 ? min : Math.min(min, value - pixels[1][index])),
|
||||
0,
|
||||
),
|
||||
max: pixels[0].reduce(
|
||||
(max, value, index) => (index % 4 === 3 ? max : Math.max(max, value - pixels[1][index])),
|
||||
0,
|
||||
),
|
||||
}
|
||||
},
|
||||
[swept.toString("base64"), settled.toString("base64")],
|
||||
)
|
||||
expect(brightness.min).toBeGreaterThanOrEqual(0)
|
||||
expect(brightness.max).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
}
|
||||
|
||||
story(`shows a static wordmark with reduced motion (${theme})`, async ({ mount, page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize({ width: 360, height: 640 })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
|
||||
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
|
||||
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
|
||||
expect(await logo.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--web-search-request")
|
||||
const card = component.getByRole("region", { name: "Third-party web search" })
|
||||
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
|
||||
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
|
||||
await card.getByRole("button", { name: "Enable", exact: true }).click()
|
||||
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
|
||||
await expect(card).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reset", exact: true }).click()
|
||||
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
story("declining search is an explicit disabled selection", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--web-search-request")
|
||||
const card = component.getByRole("region", { name: "Third-party web search" })
|
||||
await card.getByRole("button", { name: "Don’t use search", exact: true }).click()
|
||||
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
|
||||
await expect(card).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("sizes provider options to their content", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--web-search-request")
|
||||
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
|
||||
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
|
||||
await expect(menu).toBeVisible()
|
||||
const metrics = await menu.evaluate((listbox) => {
|
||||
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
|
||||
const longest = items
|
||||
.flatMap((item) => {
|
||||
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
|
||||
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
|
||||
return label && check ? [{ label, check }] : []
|
||||
})
|
||||
.toSorted((a, b) => b.label.width - a.label.width)[0]
|
||||
return {
|
||||
width: listbox.getBoundingClientRect().width,
|
||||
gap: longest ? longest.check.left - longest.label.right : 0,
|
||||
}
|
||||
})
|
||||
expect(metrics.width).toBeLessThan(160)
|
||||
expect(metrics.gap).toBe(24)
|
||||
})
|
||||
|
||||
for (const width of [360, 1200]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const component = await mount("app-current-session-surface--web-search-request", {
|
||||
globals: { theme, direction },
|
||||
})
|
||||
const card = component.getByRole("region", { name: "Third-party web search" })
|
||||
const select = card.getByRole("button", { name: /^Search provider/ })
|
||||
await expect(select).toBeEnabled()
|
||||
await expect(card).toHaveCSS("direction", direction)
|
||||
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
|
||||
await select.focus()
|
||||
await select.press("Enter")
|
||||
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
|
||||
await expect(list).toHaveCSS("direction", direction)
|
||||
await list.getByRole("option", { name: "Parallel", exact: true }).click()
|
||||
await expect(select).toHaveText("Parallel")
|
||||
await expect(card).toBeVisible()
|
||||
await expect(component.getByRole("status")).toHaveText("Ready")
|
||||
await expect(select).toBeFocused()
|
||||
await select.press("Tab")
|
||||
await expect(card.getByRole("button", { name: "Don’t use search", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
const enable = card.getByRole("button", { name: "Enable", exact: true })
|
||||
await expect(enable).toBeFocused()
|
||||
await enable.press("Enter")
|
||||
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
|
||||
await expect(card).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ The suite contains:
|
||||
- home-session click timing split between content and titlebar-tab paint
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- large-session search scan, first-result reveal, and highlight stabilization
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
- retained renderer heap with a large model catalog across repeated session navigation
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture"
|
||||
import {
|
||||
collectTimelineSearchMetrics,
|
||||
installTimelineSearchProbe,
|
||||
waitForStableTimelineSearch,
|
||||
} from "./session-timeline-search-probe"
|
||||
|
||||
benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320)
|
||||
const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000)
|
||||
const query = "Historical prompt"
|
||||
const targetPartID = "msg_0000_0000_a_user:text:0"
|
||||
const expectedCounter = `1/${historyTurns}`
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch: 1,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(1))
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
// Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly.
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open")))
|
||||
|
||||
const search = page.locator('[data-component="timeline-search-bar"]')
|
||||
const field = search.getByRole("searchbox", { name: "Find..." })
|
||||
const count = search.locator('[data-slot="timeline-search-count"]')
|
||||
const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`)
|
||||
await expect(field).toBeVisible()
|
||||
await expect(field).toBeFocused()
|
||||
await installTimelineSearchProbe(page, { targetPartID })
|
||||
|
||||
await field.fill(query)
|
||||
await expect(count).toHaveText(expectedCounter)
|
||||
await expect(target).toBeVisible({ timeout: completionTimeout })
|
||||
await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout })
|
||||
const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID })
|
||||
|
||||
expect(metrics.summary.handlerDurationMs).toBeDefined()
|
||||
expect(metrics.summary.firstCountObservedMs).toBeDefined()
|
||||
expect(metrics.summary.firstTargetVisibleMs).toBeDefined()
|
||||
expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined()
|
||||
expect(metrics.summary.stableResultObservedMs).toBeDefined()
|
||||
expect(metrics.summary.activeHighlightRanges).toBe(1)
|
||||
report(metrics, { historyTurns, query, expectedMatches: historyTurns })
|
||||
|
||||
// Check navigation and the V2 assistant content IDs outside the measured interval.
|
||||
await field.press("Enter")
|
||||
await expect(count).toHaveText(`2/${historyTurns}`)
|
||||
await field.press("Shift+Enter")
|
||||
await expect(count).toHaveText(expectedCounter)
|
||||
await field.fill("Implementation plan")
|
||||
await expect(count).toHaveText("1/1")
|
||||
await expect(fixture.text).toBeInViewport()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0]
|
||||
return range?.startContainer.parentElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset
|
||||
.timelinePartId
|
||||
}),
|
||||
)
|
||||
.toBe(textPartID)
|
||||
await field.press("Escape")
|
||||
await expect(search).toBeHidden()
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type TimelineSearchSample = {
|
||||
observedAtMs: number
|
||||
counter: string
|
||||
targetMounted: boolean
|
||||
targetVisible: boolean
|
||||
targetTopPx?: number
|
||||
activeRanges: number
|
||||
activePartID?: string
|
||||
activeVisible: boolean
|
||||
scrollTopPx: number
|
||||
}
|
||||
|
||||
type TimelineSearchProbe = {
|
||||
samples: TimelineSearchSample[]
|
||||
handlerDurationMs?: number
|
||||
initialScrollTopPx: number
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) {
|
||||
await page.evaluate(({ targetPartID }) => {
|
||||
const search = document.querySelector<HTMLElement>('[data-component="timeline-search-bar"]')
|
||||
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes")
|
||||
|
||||
const samples: TimelineSearchSample[] = []
|
||||
const initialScrollTopPx = root.scrollTop
|
||||
let startedAt: number | undefined
|
||||
let handlerDurationMs: number | undefined
|
||||
let frame: number | undefined
|
||||
let running = true
|
||||
|
||||
const visibleInRoot = (rect: DOMRect) => {
|
||||
const viewport = root.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
}
|
||||
const sample = () => {
|
||||
if (!running || startedAt === undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
setTimeout(() => {
|
||||
if (!running || startedAt === undefined) return
|
||||
const target = root.querySelector<HTMLElement>(`[data-timeline-part-id="${targetPartID}"]`)
|
||||
const targetRect = target?.getBoundingClientRect()
|
||||
const highlight = CSS.highlights.get("timeline-search-hit-active")
|
||||
const ranges = highlight ? [...highlight] : []
|
||||
const active = ranges.find((range): range is Range => range instanceof Range)
|
||||
const activeRect = active?.getBoundingClientRect()
|
||||
const activeElement =
|
||||
active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement
|
||||
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - startedAt,
|
||||
counter:
|
||||
search.querySelector<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
|
||||
targetMounted: !!target,
|
||||
targetVisible: !!targetRect && visibleInRoot(targetRect),
|
||||
targetTopPx: targetRect?.top,
|
||||
activeRanges: ranges.length,
|
||||
activePartID: activeElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId,
|
||||
activeVisible: !!activeRect && visibleInRoot(activeRect),
|
||||
scrollTopPx: root.scrollTop,
|
||||
})
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
const onInputCapture = (event: Event) => {
|
||||
if (event.target !== field || startedAt !== undefined) return
|
||||
startedAt = performance.now()
|
||||
sample()
|
||||
}
|
||||
const onInput = (event: Event) => {
|
||||
if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return
|
||||
handlerDurationMs = performance.now() - startedAt
|
||||
}
|
||||
document.addEventListener("input", onInputCapture, { capture: true })
|
||||
document.addEventListener("input", onInput)
|
||||
;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = {
|
||||
samples,
|
||||
initialScrollTopPx,
|
||||
get handlerDurationMs() {
|
||||
return handlerDurationMs
|
||||
},
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("input", onInputCapture, { capture: true })
|
||||
document.removeEventListener("input", onInput)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitForStableTimelineSearch(
|
||||
page: Page,
|
||||
input: { counter: string; targetPartID: string; timeout: number },
|
||||
) {
|
||||
await page.waitForFunction(
|
||||
({ counter, targetPartID }) => {
|
||||
const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
|
||||
?.samples
|
||||
if (!samples) return false
|
||||
return samples.some((_, index) => {
|
||||
const stable = samples.slice(index, index + 3)
|
||||
if (stable.length !== 3) return false
|
||||
return stable.every(
|
||||
(sample, sampleIndex) =>
|
||||
sample.counter === counter &&
|
||||
sample.targetVisible &&
|
||||
sample.activeRanges === 1 &&
|
||||
sample.activePartID === targetPartID &&
|
||||
sample.activeVisible &&
|
||||
(sampleIndex === 0 ||
|
||||
(Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 &&
|
||||
Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
|
||||
)
|
||||
})
|
||||
},
|
||||
{ counter: input.counter, targetPartID: input.targetPartID },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) {
|
||||
const result = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
|
||||
if (!probe) throw new Error("missing timeline search benchmark probe")
|
||||
probe.stop()
|
||||
return {
|
||||
samples: probe.samples,
|
||||
handlerDurationMs: probe.handlerDurationMs,
|
||||
initialScrollTopPx: probe.initialScrollTopPx,
|
||||
}
|
||||
})
|
||||
const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs
|
||||
const stable = result.samples.findIndex((_, index) => {
|
||||
const samples = result.samples.slice(index, index + 3)
|
||||
if (samples.length !== 3) return false
|
||||
return samples.every(
|
||||
(sample, sampleIndex) =>
|
||||
sample.counter === input.counter &&
|
||||
sample.targetVisible &&
|
||||
sample.activeRanges === 1 &&
|
||||
sample.activePartID === input.targetPartID &&
|
||||
sample.activeVisible &&
|
||||
(sampleIndex === 0 ||
|
||||
(Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 &&
|
||||
Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
|
||||
)
|
||||
})
|
||||
const final = result.samples.at(-1)
|
||||
|
||||
return {
|
||||
summary: {
|
||||
handlerDurationMs: result.handlerDurationMs,
|
||||
firstCountObservedMs: first((sample) => sample.counter === input.counter),
|
||||
firstTargetMountedMs: first((sample) => sample.targetMounted),
|
||||
firstTargetVisibleMs: first((sample) => sample.targetVisible),
|
||||
firstActiveHighlightObservedMs: first(
|
||||
(sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible,
|
||||
),
|
||||
stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined,
|
||||
initialScrollTopPx: result.initialScrollTopPx,
|
||||
finalScrollTopPx: final?.scrollTopPx,
|
||||
scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx),
|
||||
activeHighlightRanges: final?.activeRanges,
|
||||
},
|
||||
samples: result.samples,
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: current.directory,
|
||||
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -7,11 +7,13 @@ const directory = "C:/OpenCode/OpenFileExpand"
|
||||
const projectID = "proj_open_file_expand"
|
||||
const sessionID = "ses_open_file_expand"
|
||||
const title = "Open file expand"
|
||||
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
|
||||
const longPath = `frontend/${longFilename}`
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -44,7 +46,17 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: longPath,
|
||||
before: "",
|
||||
after: "export const added = true\n",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "added",
|
||||
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
|
||||
},
|
||||
],
|
||||
fileList: (path) => {
|
||||
if (path === "frontend\\" || path === "frontend") {
|
||||
return [
|
||||
@@ -55,6 +67,13 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
name: longFilename,
|
||||
path: `frontend\\${longFilename}`,
|
||||
absolute: `${directory}/${longPath}`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (path) return []
|
||||
@@ -75,6 +94,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
},
|
||||
]
|
||||
},
|
||||
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
@@ -119,6 +139,93 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
await frontendRow.click()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
const longRow = panel.getByRole("button", { name: longFilename })
|
||||
await expect(longRow).toBeVisible()
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
|
||||
expect(
|
||||
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
|
||||
).toBe("clip")
|
||||
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
|
||||
await viewport.evaluate((element) => element.clientWidth),
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
|
||||
const widths = rows.map((row) => row.getBoundingClientRect().width)
|
||||
return Math.max(...widths) - Math.min(...widths)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
|
||||
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
|
||||
await expect(status).toHaveText("A")
|
||||
const statusBox = await status.boundingBox()
|
||||
if (!statusBox) throw new Error("File status has no bounding box")
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
|
||||
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
|
||||
|
||||
await viewport.hover()
|
||||
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
|
||||
await expect(horizontalThumb).toHaveCSS("opacity", "1")
|
||||
await page.mouse.wheel(1_000, 0)
|
||||
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
|
||||
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
|
||||
await expect
|
||||
.poll(() =>
|
||||
status.evaluate((element) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
return viewport.right - element.getBoundingClientRect().right
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(24)
|
||||
|
||||
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
|
||||
const thumbBox = await horizontalThumb.boundingBox()
|
||||
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
|
||||
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
|
||||
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await filter.fill(longFilename)
|
||||
const filteredRow = panel.getByRole("option", { name: longFilename })
|
||||
await expect(filteredRow).toBeVisible()
|
||||
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
|
||||
await expect(filteredStatus).toHaveText("A")
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
|
||||
|
||||
await viewport.evaluate((element) => {
|
||||
element.setAttribute("dir", "rtl")
|
||||
element.scrollLeft = 0
|
||||
element.dispatchEvent(new Event("scroll"))
|
||||
})
|
||||
await expect
|
||||
.poll(() =>
|
||||
filteredStatus.evaluate((element) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
return element.getBoundingClientRect().left - viewport.left
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(24)
|
||||
const rtlThumbBox = await horizontalThumb.boundingBox()
|
||||
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
|
||||
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
|
||||
await viewport.evaluate((element) => {
|
||||
element.removeAttribute("dir")
|
||||
element.scrollLeft = 0
|
||||
element.dispatchEvent(new Event("scroll"))
|
||||
})
|
||||
|
||||
await filter.fill("")
|
||||
|
||||
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||
await expect(appRow).toBeVisible()
|
||||
await appRow.click()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.use({
|
||||
deviceScaleFactor: 2,
|
||||
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
|
||||
launchOptions: { args: ["--force-device-scale-factor=2"] },
|
||||
})
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -396,7 +396,12 @@ async function mockServers(
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -53,7 +53,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(trigger.locator("use")).toHaveAttribute(
|
||||
"href",
|
||||
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
|
||||
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
|
||||
)
|
||||
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await trigger.hover()
|
||||
@@ -267,7 +267,7 @@ for (const state of ["closed", "unopened"] as const) {
|
||||
messages.resolve()
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
|
||||
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-worktree")
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
|
||||
@@ -154,8 +154,8 @@ test("worktree deletion sends the project location separately from the target",
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
|
||||
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
|
||||
await settings.getByRole("button", { name: "Delete “workspace-1”?", exact: true }).click()
|
||||
const confirmation = page.getByRole("dialog", { name: "Delete “workspace-1”?", exact: true })
|
||||
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
|
||||
await expect(remove).toBeEnabled()
|
||||
const deleting = page.waitForRequest(
|
||||
|
||||
@@ -225,19 +225,90 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(sidebar).toHaveCSS("width", "130px")
|
||||
await expect(sidebar).toHaveCSS("width", "140px")
|
||||
|
||||
await tabB.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
for (const count of [0, 26]) {
|
||||
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, directory, count }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_navigation_${index}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server, directory: sessionA.directory, count },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
|
||||
for (const width of [260, 180, 140]) {
|
||||
if (width !== 260) {
|
||||
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
|
||||
const bounds = await sidebar.boundingBox()
|
||||
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(sidebar).toHaveCSS("width", `${width}px`)
|
||||
await testInfo.attach(`navigation-${count}-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const button = sidebar.getByRole("button", { name, exact: true })
|
||||
const label = button.getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await button.hover()
|
||||
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await expect
|
||||
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.getByRole("main").hover()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
@@ -254,23 +325,27 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(settings).toHaveText("Settings")
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(settings).toBeInViewport({ ratio: 1 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(settings).toHaveCSS("margin-top", "8px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
@@ -285,12 +360,12 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinned = await settings.boundingBox()
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await testInfo.attach(`vertical-tabs-settings-${width}`, {
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -304,17 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await settings.click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(settings).toHaveAttribute("aria-pressed", "true")
|
||||
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeHidden()
|
||||
await settings.focus()
|
||||
await settings.press("Enter")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,9 +431,9 @@ for (const profile of [
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await expect(hint).toBeHidden()
|
||||
await button.hover()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect(hint).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
@@ -373,7 +445,7 @@ for (const profile of [
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await expect(hint).toBeHidden()
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
@@ -381,10 +453,10 @@ for (const profile of [
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -560,7 +632,12 @@ async function mockServer(page: Page) {
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: sessionA.directory,
|
||||
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -67,12 +67,12 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectToken(
|
||||
@@ -92,7 +92,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
@@ -109,14 +109,14 @@ for (const theme of ["light", "dark"] as const) {
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
@@ -127,29 +127,29 @@ for (const theme of ["light", "dark"] as const) {
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
await expectBackground(stop, "icon-button-contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expectBackground(view.send, "icon-button-contrast")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ function ComposerStory(props: {
|
||||
label?: string
|
||||
inspectRequest?: boolean
|
||||
continueOnStop?: boolean
|
||||
longLabels?: boolean
|
||||
alternate?: "queue" | "steer"
|
||||
}) {
|
||||
const [draft, setDraft] = createStore<ComposerPersistedState>({
|
||||
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
@@ -66,11 +68,15 @@ function ComposerStory(props: {
|
||||
activity: props.label ?? "Ready",
|
||||
variant: STORY_MODEL.variant,
|
||||
})
|
||||
const modelOption = createMemo(() => ({
|
||||
...selectedModel,
|
||||
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
|
||||
}))
|
||||
const modelSelection = {
|
||||
ready: Object.assign(() => true, { promise: undefined }),
|
||||
current: () => selectedModel,
|
||||
recent: () => [selectedModel],
|
||||
list: () => [selectedModel],
|
||||
current: () => modelOption(),
|
||||
recent: () => [modelOption()],
|
||||
list: () => [modelOption()],
|
||||
cycle() {},
|
||||
set() {},
|
||||
visible: () => true,
|
||||
@@ -126,7 +132,7 @@ function ComposerStory(props: {
|
||||
placeholder: () => "Ask anything, / for commands, @ for context...",
|
||||
agent: {
|
||||
options: () => [
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
|
||||
{ id: "review", label: "review" },
|
||||
],
|
||||
current: () => "build",
|
||||
@@ -135,7 +141,7 @@ function ComposerStory(props: {
|
||||
variant: {
|
||||
options: () => [
|
||||
{ id: "default", label: "default" },
|
||||
{ id: "balanced", label: "balanced" },
|
||||
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
current: () => story.variant,
|
||||
@@ -144,6 +150,17 @@ function ComposerStory(props: {
|
||||
submit: {
|
||||
stopping: () => !!props.stopping,
|
||||
working: () => !!props.working,
|
||||
queue: props.alternate
|
||||
? {
|
||||
count: () => 0,
|
||||
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
|
||||
alternate: () => props.alternate,
|
||||
editing: () => undefined,
|
||||
confirmEdit() {},
|
||||
cancelEdit() {},
|
||||
editFirst: () => false,
|
||||
}
|
||||
: undefined,
|
||||
onSubmit: () => {
|
||||
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
|
||||
const request = props.inspectRequest
|
||||
@@ -290,6 +307,21 @@ export const NarrowLayout = {
|
||||
),
|
||||
}
|
||||
|
||||
export const ToolbarOverflow = {
|
||||
args: { alternate: "queue" },
|
||||
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
|
||||
render: (args: { alternate: "none" | "queue" | "steer" }) => (
|
||||
<div class="w-[min(420px,calc(100vw-80px))]">
|
||||
<ComposerStory
|
||||
longLabels
|
||||
working
|
||||
prompt={text("Keep all controls reachable when the toolbar overflows")}
|
||||
alternate={args.alternate === "none" ? undefined : args.alternate}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DemoFirstClassSkillIDs = {
|
||||
name: "Demo: First-class skill IDs",
|
||||
render: () => (
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
|
||||
[data-slot="composer-controls"][data-overflow-start="true"] {
|
||||
--mask-start: 16px;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"][data-overflow-end="true"] {
|
||||
--mask-end: 16px;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"]:dir(rtl) {
|
||||
--mask-direction: to left;
|
||||
}
|
||||
|
||||
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
mask-image: linear-gradient(
|
||||
var(--mask-direction, to right),
|
||||
transparent,
|
||||
black var(--mask-start, 0px),
|
||||
black calc(100% - var(--mask-end, 0px)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let controlsViewport!: HTMLDivElement
|
||||
let controlsContent!: HTMLDivElement
|
||||
const [overflow, setOverflow] = createStore({ start: false, end: false })
|
||||
const updateOverflow = () => {
|
||||
const offset = Math.abs(controlsViewport.scrollLeft)
|
||||
setOverflow({
|
||||
start: offset > 1,
|
||||
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(updateOverflow)
|
||||
observer.observe(controlsViewport)
|
||||
observer.observe(controlsContent)
|
||||
updateOverflow()
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
@@ -226,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-1"
|
||||
class="flex shrink-0 items-center"
|
||||
aria-hidden={state.mode === "shell"}
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
@@ -245,64 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onContext={props.controller.openContext}
|
||||
onShell={props.controller.openShell}
|
||||
/>
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
</div>
|
||||
<div
|
||||
ref={controlsViewport}
|
||||
data-slot="composer-controls"
|
||||
data-overflow-start={overflow.start}
|
||||
data-overflow-end={overflow.end}
|
||||
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
|
||||
onScroll={updateOverflow}
|
||||
aria-hidden={state.mode === "shell"}
|
||||
inert={state.mode === "shell" ? true : undefined}
|
||||
style={buttons()}
|
||||
>
|
||||
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
<div data-slot="composer-actions" class="flex shrink-0 items-center">
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -808,11 +842,7 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
class="size-7 rounded-md p-[6px] disabled:opacity-50"
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -7,5 +7,7 @@ export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./r
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
|
||||
export { flushPersisted } from "./runtime/persistence/persist"
|
||||
export { useWslServers } from "./servers/wsl/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
@@ -34,6 +34,14 @@ export function createHomeController() {
|
||||
const conn = list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
const project = home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const project = homeProjectForSession(session, home.project.list())
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const connKey = ServerConnection.key(conn)
|
||||
|
||||
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("labels a worktree session with its project before that project's inventory has loaded", () => {
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [{ ...opened, name: "Project A" }],
|
||||
})
|
||||
|
||||
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
|
||||
expect(records[0]?.projectName).toBe("Project A")
|
||||
})
|
||||
|
||||
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
|
||||
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [opened, nested],
|
||||
})
|
||||
|
||||
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const project = input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
|
||||
// the session's project ID still identifies its added project.
|
||||
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: readonly T[],
|
||||
) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
return (
|
||||
projects.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projects.find((item) => item.id === session.projectID)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit) {
|
||||
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit-active) {
|
||||
background-color: var(--v2-icon-icon-accent);
|
||||
color: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
|
||||
/** Allows the prompt to extend 80px beyond each side of the 720px wordmark. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[880px] px-0"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
@@ -23,6 +22,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ProviderTipSchema = Persistence.struct({
|
||||
dismissedAt: Schema.Finite,
|
||||
})
|
||||
|
||||
export const WorkspaceTipSchema = ProviderTipSchema
|
||||
|
||||
export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
@@ -61,7 +63,7 @@ export function NewSessionView(props: {
|
||||
/>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<NewSessionWordmark />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
@@ -99,7 +101,15 @@ export function NewSessionView(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderTip />
|
||||
<NewSessionTips
|
||||
workspaceEligible={
|
||||
!!props.project.selected() &&
|
||||
props.workspace.bar.visible() &&
|
||||
props.workspace.selection.value() !== "create" &&
|
||||
props.workspace.project.managed() === 0
|
||||
}
|
||||
onWorkspace={() => select("create")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -118,56 +128,90 @@ export function NewSessionStatus(props: { visible: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderTip() {
|
||||
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
const [providerState, setProviderState, , providerReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
ProviderTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
const visible = createMemo(
|
||||
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
|
||||
Persist.global("new-session.workspace-tip"),
|
||||
WorkspaceTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
const workspaceVisible = createMemo(
|
||||
() =>
|
||||
props.workspaceEligible &&
|
||||
workspaceReady() &&
|
||||
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const providerVisible = createMemo(
|
||||
() =>
|
||||
providers.ready() &&
|
||||
persistedReady() &&
|
||||
providerReady() &&
|
||||
providers.paid().length === 0 &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
|
||||
if (providerVisible()) return "provider"
|
||||
if (workspaceVisible()) return "workspace"
|
||||
})
|
||||
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: visible,
|
||||
show: () => tip() !== undefined,
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
const openProviders = () => {
|
||||
const open = () => {
|
||||
const current = tip()
|
||||
if (!current) return
|
||||
if (current === "workspace") {
|
||||
setWorkspaceState("dismissedAt", Date.now())
|
||||
props.onWorkspace()
|
||||
return
|
||||
}
|
||||
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
|
||||
})
|
||||
}
|
||||
const dismiss = () => {
|
||||
const current = tip()
|
||||
if (!current) return
|
||||
if (current === "workspace") {
|
||||
setWorkspaceState("dismissedAt", Date.now())
|
||||
return
|
||||
}
|
||||
setProviderState("dismissedAt", Date.now())
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
data-component="new-session-tip"
|
||||
data-visible={tip() !== undefined}
|
||||
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={openProviders}
|
||||
onClick={open}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="truncate">
|
||||
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
|
||||
</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
@@ -176,7 +220,7 @@ function ProviderTip() {
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
[data-component="new-session-wordmark"] {
|
||||
--wordmark-shimmer-opacity: 0.45;
|
||||
|
||||
.wordmark-shimmer {
|
||||
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
|
||||
color: white;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
|
||||
mask-size: 300% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
animation: new-session-wordmark-shimmer 800ms linear both;
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
|
||||
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
|
||||
--wordmark-shimmer-opacity: 0.0672;
|
||||
}
|
||||
|
||||
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
|
||||
@keyframes new-session-wordmark-shimmer {
|
||||
0% {
|
||||
mask-position: 100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
20%,
|
||||
80% {
|
||||
opacity: var(--wordmark-shimmer-opacity);
|
||||
}
|
||||
100% {
|
||||
mask-position: 0% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="new-session-wordmark"] {
|
||||
.wordmark-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
export default {
|
||||
title: "App/New Session/Wordmark",
|
||||
id: "app-new-session-wordmark",
|
||||
component: NewSessionWordmark,
|
||||
}
|
||||
|
||||
export const Reveal = {
|
||||
render: () => <NewSessionWordmark />,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import "./wordmark.css"
|
||||
|
||||
export function NewSessionWordmark() {
|
||||
return (
|
||||
<div
|
||||
data-component="new-session-wordmark"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
|
||||
>
|
||||
<div data-slot="wordmark-reveal" class="relative">
|
||||
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
|
||||
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
sameDirectory,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
workspaceSelectionDestination,
|
||||
} from "@/workspaces/paths"
|
||||
|
||||
@@ -56,6 +56,50 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
return current ? normalizeProjectInfo(current) : undefined
|
||||
})
|
||||
const worktreeSource = createMemo(
|
||||
() => {
|
||||
const project = currentProject()
|
||||
return project ? { projectID: project.id, directory: project.worktree } : undefined
|
||||
},
|
||||
undefined,
|
||||
{ equals: (a, b) => a?.projectID === b?.projectID && a?.directory === b?.directory },
|
||||
)
|
||||
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
|
||||
projectID: source.projectID,
|
||||
items: await serverSDK.api.worktree
|
||||
.list({ location: { directory: source.directory } })
|
||||
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
|
||||
}))
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
const worktreeItems = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
const loaded = worktrees.latest
|
||||
return loaded?.projectID === project.id ? loaded.items : project.worktrees
|
||||
})
|
||||
const worktreeDirectories = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
const directories = [
|
||||
...worktreeItems().map((item) => item.directory),
|
||||
...project.worktrees.map((item) => item.directory),
|
||||
...(project.sandboxes ?? []),
|
||||
]
|
||||
return directories
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory))
|
||||
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
|
||||
})
|
||||
const managedWorktrees = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return 0
|
||||
return worktreeItems().filter(
|
||||
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
|
||||
).length
|
||||
})
|
||||
const visible = createMemo(() =>
|
||||
resolveNewSessionGit({
|
||||
projectVcs: currentProject()?.vcs,
|
||||
@@ -66,7 +110,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const project = currentProject()
|
||||
const worktree = input.selectedWorktree()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
return isWorkspaceSelection(project, worktree) ||
|
||||
worktreeDirectories().some((item) => sameDirectory(item, worktree))
|
||||
? worktree
|
||||
: undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = currentProject()
|
||||
@@ -97,7 +144,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
() => undefined,
|
||||
)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
})
|
||||
const branch = createMemo(() =>
|
||||
@@ -122,7 +169,12 @@ export function createNewSessionWorkspaceController(input: {
|
||||
workspace: createMemo(() => {
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
return (
|
||||
current === "create" ||
|
||||
(!!project &&
|
||||
(isWorkspaceDirectory(project, current) ||
|
||||
worktreeDirectories().some((item) => sameDirectory(item, current))))
|
||||
)
|
||||
}),
|
||||
reset: () => {
|
||||
input.setSelectedWorktree(undefined)
|
||||
@@ -142,10 +194,8 @@ export function createNewSessionWorkspaceController(input: {
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => {
|
||||
const project = currentProject()
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
workspaces: worktreeDirectories,
|
||||
managed: managedWorktrees,
|
||||
git: visible,
|
||||
branches: () => {
|
||||
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -25,6 +26,9 @@ export function PromptWorkspaceSelector(props: {
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
const branchTruncation = createTruncatedText()
|
||||
const focusWorktreeSearch = () =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
|
||||
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
@@ -34,8 +38,8 @@ export function PromptWorkspaceSelector(props: {
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace-isolated"
|
||||
if (selected() === "create") return "plus"
|
||||
return "outline-worktree"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = { type: "select", value }
|
||||
@@ -66,13 +70,14 @@ export function PromptWorkspaceSelector(props: {
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
@@ -89,7 +94,10 @@ export function PromptWorkspaceSelector(props: {
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
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"
|
||||
>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
@@ -109,14 +117,8 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
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>
|
||||
}
|
||||
value={language.t("session.new.workspace.local.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
@@ -126,24 +128,22 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => select("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Icon name="plus" />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.new.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</Tooltip>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
@@ -156,10 +156,11 @@ export function PromptWorkspaceSelector(props: {
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
focusWorktreeSearch()
|
||||
}}
|
||||
>
|
||||
<Menu.SubTrigger
|
||||
onClick={focusWorktreeSearch}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
@@ -170,7 +171,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
@@ -205,7 +206,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
@@ -213,6 +214,11 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
<Show when={search.workspaces.trim() && workspaces().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
@@ -232,6 +238,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
@@ -245,7 +252,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
>
|
||||
<Menu.Trigger 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 transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
@@ -319,6 +326,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const truncation = createTruncatedText()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
@@ -338,15 +346,27 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={value()}
|
||||
disabled={!truncation.truncated()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
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 name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
<span ref={truncation.observe} class="min-w-0 truncate">
|
||||
{value()}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function createTruncatedText() {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
return {
|
||||
truncated,
|
||||
observe: (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,7 @@ export const dict = {
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.files": "Files",
|
||||
@@ -676,7 +677,7 @@ export const dict = {
|
||||
"session.error.incompatible.description":
|
||||
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
|
||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
||||
"session.background.moveRunning": "Move running work to background",
|
||||
"session.background.moveRunning": "Move to background",
|
||||
"session.background.inBackground": "Running {{tasks}} in background",
|
||||
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
|
||||
"session.background.running": "Running work in background",
|
||||
@@ -736,6 +737,16 @@ export const dict = {
|
||||
"session.todo.expand": "Expand",
|
||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.websearch.title": "Third-party web search",
|
||||
"session.websearch.description": "Select the search provider agents use to search the web",
|
||||
"session.websearch.provider": "Search provider",
|
||||
"session.websearch.any": "Any",
|
||||
"session.websearch.disable": "Don’t use search",
|
||||
"session.websearch.enable": "Enable",
|
||||
"session.websearch.loadFailed": "Could not load search providers.",
|
||||
"session.websearch.empty": "No search providers available.",
|
||||
"session.websearch.failed": "Could not save your choice. Please try again.",
|
||||
"session.websearch.retry": "Retry",
|
||||
"session.question.minimize": "Minimize question",
|
||||
"session.question.restore": "Restore question",
|
||||
"session.question.pending.one": "{{count}} pending question",
|
||||
@@ -759,6 +770,8 @@ export const dict = {
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
"session.header.searchFiles": "Search files",
|
||||
"session.search.placeholder": "Find...",
|
||||
"session.search.noResults": "No matches",
|
||||
"session.header.openIn": "Open in",
|
||||
"session.header.open.action": "Open {{app}}",
|
||||
"session.header.open.ariaLabel": "Open in {{app}}",
|
||||
@@ -1045,8 +1058,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "Project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.showProjectIcon.title": "Show project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description": "Place the title bar at the bottom of the screen on mobile",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
@@ -1238,30 +1251,43 @@ export const dict = {
|
||||
|
||||
"workspace.new": "New worktree",
|
||||
"common.viewAll": "View all",
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.local.tooltip": "Uses project’s current checkout",
|
||||
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.createFrom": "Create from branch",
|
||||
"session.new.workspace.branch.search.placeholder": "Search branches",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search worktrees",
|
||||
"session.new.workspace.search.empty": "No matching worktrees",
|
||||
"settings.tab.workspaces": "Worktrees",
|
||||
"settings.workspaces.description": "Review worktrees and manage disk usage",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
"settings.workspaces.empty": "No worktrees",
|
||||
"settings.workspaces.empty": "No worktrees yet",
|
||||
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
|
||||
"settings.workspaces.count.one": "{{count}} worktree",
|
||||
"settings.workspaces.count.other": "{{count}} worktrees",
|
||||
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||
"settings.workspaces.sessions.filtered.one": "{{count}} session",
|
||||
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
|
||||
"settings.workspaces.lastActiveSession": "Last active session",
|
||||
"settings.workspaces.deleteAll": "Delete all worktrees",
|
||||
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
|
||||
"settings.workspaces.delete.warning":
|
||||
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
|
||||
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
|
||||
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
|
||||
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
|
||||
"settings.workspaces.deleteWithoutSessions.confirm.one":
|
||||
"This will permanently delete {{count}} worktree without linked sessions.",
|
||||
"settings.workspaces.deleteWithoutSessions.confirm.other":
|
||||
"This will permanently delete {{count}} worktrees without linked sessions.",
|
||||
"settings.workspaces.deleteWithoutSessions.warning":
|
||||
"Worktrees with unmerged changes or active locations will be kept.",
|
||||
"settings.workspaces.delete.button": "Delete worktrees",
|
||||
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
|
||||
"settings.workspaces.deleteAll.warning":
|
||||
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
|
||||
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked":
|
||||
"Linked sessions will remain, but their working directory will be permanently removed.",
|
||||
"Linked sessions will remain, but their working directory will no longer exist.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
@@ -1272,9 +1298,10 @@ export const dict = {
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating worktree",
|
||||
"workspace.lifecycle.created": "Worktree created",
|
||||
"workspace.lifecycle.deleting": "Deleting…",
|
||||
"workspace.lifecycle.starting": "Starting session",
|
||||
"workspace.onboarding.title": "Isolate sessions with worktrees",
|
||||
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||
"workspace.onboarding.description": "Each gets its own checkout",
|
||||
"workspace.lifecycle.moving": "Moving to worktree",
|
||||
"workspace.lifecycle.set": "Worktree set",
|
||||
"session.summary.title": "Session details",
|
||||
@@ -1293,9 +1320,10 @@ export const dict = {
|
||||
"workspace.status.checking": "Checking for unmerged changes…",
|
||||
"workspace.status.error": "Unable to verify git status.",
|
||||
"workspace.status.clean": "No unmerged changes detected.",
|
||||
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
|
||||
"workspace.status.dirty": "Unmerged changes will be lost.",
|
||||
"workspace.delete.title": "Delete worktree",
|
||||
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
|
||||
"workspace.delete.confirm": "Delete “{{name}}”?",
|
||||
"workspace.delete.location": "Location",
|
||||
"workspace.delete.button": "Delete worktree",
|
||||
"workspace.reset.title": "Reset worktree",
|
||||
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
|
||||
|
||||
@@ -49,6 +49,36 @@ migration rules remain explicit in their schemas.
|
||||
invalid entries individually. Valid entries still pass through their codecs.
|
||||
- Recovery is not a substitute for an explicit historical shape transformation.
|
||||
|
||||
## Writes
|
||||
|
||||
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
|
||||
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
|
||||
hides, and the write is skipped when the serialized form did not change. Reactive observers
|
||||
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
|
||||
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
|
||||
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
|
||||
change wins over a value arriving from another window, and over a stored value that finishes
|
||||
loading after the user already edited. A remote value that arrives while the store is dirty
|
||||
is held until the save runs; if the local setter calls turned out not to change the
|
||||
serialized form, the remote value is adopted instead of being lost.
|
||||
|
||||
## Namespaces
|
||||
|
||||
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
|
||||
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
|
||||
namespace is loaded from the host once, reads are Map lookups from then on, and writes
|
||||
update the cache immediately while being batched into one host round trip per flush
|
||||
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
|
||||
flush on page hide is on the wire before the page goes away; the desktop platform flushes
|
||||
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
|
||||
local write carries a sequence number that is kept until the host accepts that exact write;
|
||||
until then neither the initial load, a change from another window (`accept`), nor the retry
|
||||
of an older failed batch can replace the key. The host also stamps every update with a
|
||||
monotonic revision, returned in the ack and carried by change events and loads, so an event
|
||||
that reaches a window after a newer ack or load for the same key is recognised as stale and
|
||||
dropped; an event held back during an in-flight write is applied after the ack when the host
|
||||
ordered it later.
|
||||
|
||||
## Migrations
|
||||
|
||||
Describe shipped representations with schemas and transform their typed values
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
|
||||
import { ModelSelectionSchema } from "@/providers/models/selection"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { FileViewsSchema } from "@/workspaces/files/view-cache"
|
||||
@@ -12,6 +12,7 @@ describe("persisted consumer schemas", () => {
|
||||
test("onboarding and provider tip retain defaults and validate stored values", () => {
|
||||
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
|
||||
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
|
||||
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
|
||||
expect(onboarding({})).toEqual({ used: false })
|
||||
expect(onboarding({ used: "true" })).toEqual({ used: false })
|
||||
expect(onboarding({ used: true })).toEqual({ used: true })
|
||||
@@ -19,6 +20,7 @@ describe("persisted consumer schemas", () => {
|
||||
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
|
||||
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
|
||||
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
|
||||
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
|
||||
})
|
||||
|
||||
test("collapse records recover malformed entries without losing valid siblings", () => {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
|
||||
|
||||
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
|
||||
// that the test delivers to other windows whenever it chooses, like the real event stream.
|
||||
function host(initial: Record<string, Record<string, string>> = {}) {
|
||||
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
|
||||
const calls: Call[] = []
|
||||
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
|
||||
let revision = 0
|
||||
let fail: (insert: Record<string, string>) => boolean = () => false
|
||||
let gate: Promise<void> | undefined
|
||||
const driver: NamespaceDriver = {
|
||||
items: async (name) => {
|
||||
calls.push({ kind: "items", name })
|
||||
return { items: Object.fromEntries(data.get(name) ?? []), revision }
|
||||
},
|
||||
update: async (name, insert, remove) => {
|
||||
calls.push({ kind: "update", name, insert, remove })
|
||||
await gate
|
||||
if (fail(insert)) throw new Error("disk full")
|
||||
const items = data.get(name) ?? new Map()
|
||||
for (const [key, value] of Object.entries(insert)) items.set(key, value)
|
||||
for (const key of remove) items.delete(key)
|
||||
data.set(name, items)
|
||||
events.push({ name, insert, remove, revision: ++revision })
|
||||
return revision
|
||||
},
|
||||
clear: async (name) => {
|
||||
calls.push({ kind: "clear", name })
|
||||
data.delete(name)
|
||||
},
|
||||
}
|
||||
return {
|
||||
driver,
|
||||
data,
|
||||
calls,
|
||||
events,
|
||||
updates: () => calls.filter((call) => call.kind === "update"),
|
||||
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
|
||||
(fail = typeof value === "boolean" ? () => value : value),
|
||||
setGate: (value: Promise<void> | undefined) => (gate = value),
|
||||
deliver: (target: NamespaceStorage, index: number) => {
|
||||
const event = events[index]!
|
||||
target.accept(event.insert, event.remove, event.revision)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("namespace storage", () => {
|
||||
test("loads the namespace once and serves reads from memory", async () => {
|
||||
const h = host({ w: { tabs: "[]", recent: "{}" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
expect(await storage.getItem("tabs")).toBe("[]")
|
||||
expect(await storage.getItem("recent")).toBe("{}")
|
||||
expect(await storage.getItem("missing")).toBeNull()
|
||||
expect(await storage.getLength()).toBe(2)
|
||||
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reads its own writes immediately and coalesces them into one update", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.setItem("recent", "{}")
|
||||
void storage.setItem("tabs", "[1,2]")
|
||||
void storage.removeItem("recent")
|
||||
expect(await storage.getItem("tabs")).toBe("[1,2]")
|
||||
expect(await storage.getItem("recent")).toBeNull()
|
||||
expect(h.updates()).toHaveLength(0)
|
||||
await wait(30)
|
||||
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
|
||||
})
|
||||
|
||||
test("writes made while loading win over the loaded snapshot", async () => {
|
||||
const h = host({ w: { tabs: "old" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
const read = storage.getItem("tabs")
|
||||
void storage.setItem("tabs", "new")
|
||||
expect(await read).toBe("new")
|
||||
})
|
||||
|
||||
test("flush writes now and resolves after the driver accepted the batch", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
await storage.flush()
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
|
||||
await storage.flush()
|
||||
expect(h.updates()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
h.setFail(true)
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.setItem("recent", "{}")
|
||||
await storage.flush()
|
||||
expect(h.data.get("w")).toBeUndefined()
|
||||
h.setFail(false)
|
||||
void storage.setItem("tabs", "[2]")
|
||||
await storage.flush()
|
||||
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
|
||||
})
|
||||
|
||||
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
|
||||
const h = host()
|
||||
const first = Promise.withResolvers<void>()
|
||||
h.setGate(first.promise)
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.flush()
|
||||
void storage.setItem("tabs", "[2]")
|
||||
void storage.flush()
|
||||
// Both batches reached the driver while the first reply is still outstanding.
|
||||
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
|
||||
first.resolve()
|
||||
await storage.flush()
|
||||
})
|
||||
|
||||
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
|
||||
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const accepted = Promise.withResolvers<number>()
|
||||
const driver: NamespaceDriver = {
|
||||
items: () => loaded.promise,
|
||||
update: () => accepted.promise,
|
||||
clear: async () => undefined,
|
||||
}
|
||||
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
|
||||
const read = storage.getItem("model")
|
||||
void storage.setItem("model", "local")
|
||||
void storage.flush()
|
||||
storage.accept({ model: "other-window-older" }, [], 1)
|
||||
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
|
||||
expect(await read).toBe("local")
|
||||
accepted.resolve(2)
|
||||
await storage.flush()
|
||||
expect(await storage.getItem("model")).toBe("local")
|
||||
storage.accept({ model: "other-window-newer" }, [], 3)
|
||||
expect(await storage.getItem("model")).toBe("other-window-newer")
|
||||
})
|
||||
|
||||
test("a failed batch does not requeue a value a later batch already replaced", async () => {
|
||||
const h = host()
|
||||
const first = Promise.withResolvers<void>()
|
||||
h.setGate(first.promise)
|
||||
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
|
||||
h.setFail((insert) => insert.tabs === "old")
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "old")
|
||||
void storage.flush()
|
||||
h.setGate(undefined)
|
||||
void storage.setItem("tabs", "new")
|
||||
void storage.flush()
|
||||
await wait(0)
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("new")
|
||||
first.resolve()
|
||||
await storage.flush()
|
||||
await storage.flush()
|
||||
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("new")
|
||||
expect(await storage.getItem("tabs")).toBe("new")
|
||||
})
|
||||
|
||||
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
|
||||
const h = host({ g: { model: "start" } })
|
||||
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
await one.getItem("model")
|
||||
await two.getItem("model")
|
||||
void one.setItem("model", "A")
|
||||
await one.flush()
|
||||
void two.setItem("model", "B")
|
||||
await two.flush()
|
||||
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
|
||||
h.deliver(two, 0)
|
||||
expect(await two.getItem("model")).toBe("B")
|
||||
expect(h.data.get("g")?.get("model")).toBe("B")
|
||||
// One still receives B, which is newer than its own ack.
|
||||
h.deliver(one, 1)
|
||||
expect(await one.getItem("model")).toBe("B")
|
||||
})
|
||||
|
||||
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
|
||||
const h = host()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
h.setGate(gate.promise)
|
||||
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
void storage.setItem("model", "mine")
|
||||
void storage.flush()
|
||||
// Another window's write for the same key landed at the host after ours will.
|
||||
storage.accept({ model: "theirs" }, [], 2)
|
||||
expect(await storage.getItem("model")).toBe("mine")
|
||||
gate.resolve()
|
||||
await storage.flush()
|
||||
expect(await storage.getItem("model")).toBe("theirs")
|
||||
})
|
||||
|
||||
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
|
||||
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
|
||||
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
|
||||
const read = storage.getItem("model")
|
||||
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
|
||||
storage.accept({ model: "inserted" }, [], 41)
|
||||
loaded.resolve({ items: {}, revision: 42 })
|
||||
expect(await read).toBeNull()
|
||||
// The delete event is older than the floor and must stay a no-op either way.
|
||||
storage.accept({}, ["model"], 42)
|
||||
expect(await storage.getItem("model")).toBeNull()
|
||||
// A key inserted by an event newer than the snapshot survives the load.
|
||||
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
|
||||
const pending = other.getItem("model")
|
||||
other.accept({ model: "after-snapshot" }, [], 43)
|
||||
second.resolve({ items: {}, revision: 42 })
|
||||
expect(await pending).toBe("after-snapshot")
|
||||
})
|
||||
|
||||
test("an event older than the initial load is ignored", async () => {
|
||||
const h = host({ g: { model: "loaded" } })
|
||||
void h.driver.update("g", { model: "loaded" }, [])
|
||||
await wait(0)
|
||||
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
await storage.getItem("model")
|
||||
storage.accept({ model: "before-load" }, [], 1)
|
||||
expect(await storage.getItem("model")).toBe("loaded")
|
||||
storage.accept({}, ["model"], 2)
|
||||
expect(await storage.getItem("model")).toBeNull()
|
||||
})
|
||||
|
||||
test("clear drops the cache and queued changes and clears the driver", async () => {
|
||||
const h = host({ w: { tabs: "[]" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
await storage.getItem("tabs")
|
||||
void storage.setItem("recent", "{}")
|
||||
await storage.clear()
|
||||
expect(await storage.getItem("tabs")).toBeNull()
|
||||
expect(await storage.getItem("recent")).toBeNull()
|
||||
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
|
||||
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
|
||||
// update with a monotonic revision and reports it with reads, acks, and change events.
|
||||
export type NamespaceDriver = {
|
||||
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
|
||||
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
|
||||
clear(name: string): Promise<void>
|
||||
}
|
||||
|
||||
export type NamespaceStorage = AsyncStorage & {
|
||||
/** Write every queued change now. Resolves when the driver has accepted it. */
|
||||
flush(): Promise<void>
|
||||
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
|
||||
accept(insert: Record<string, string>, remove: string[], revision: number): void
|
||||
}
|
||||
|
||||
export const namespaceFlushDelay = 100
|
||||
|
||||
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
|
||||
// on; writes update the cache immediately and are batched into one driver call per flush window,
|
||||
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
|
||||
//
|
||||
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
|
||||
// recorded until the host acks that exact write; while recorded, nothing external may replace
|
||||
// the key. Every value the cache holds also carries the host revision it came from, so an event
|
||||
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
|
||||
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
|
||||
// on the wire before the page goes away.
|
||||
export function createNamespaceStorage(
|
||||
driver: NamespaceDriver,
|
||||
name: string,
|
||||
options: { delay?: number } = {},
|
||||
): NamespaceStorage {
|
||||
const delay = options.delay ?? namespaceFlushDelay
|
||||
const cache = new Map<string, string>()
|
||||
const local = new Map<string, { seq: number; value: string | null }>()
|
||||
const dirty = new Set<string>()
|
||||
const inflight = new Set<Promise<void>>()
|
||||
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
|
||||
const applied = new Map<string, number>()
|
||||
let floor = -1
|
||||
// The newest external change for a key that arrived while a local write was still in flight.
|
||||
const deferred = new Map<string, { revision: number; value: string | null }>()
|
||||
let seq = 0
|
||||
let loading: Promise<void> | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const place = (key: string, value: string | null, revision: number) => {
|
||||
if (value === null) cache.delete(key)
|
||||
else cache.set(key, value)
|
||||
applied.set(key, revision)
|
||||
}
|
||||
|
||||
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
|
||||
// an older event inserted it into the cache while the load was in flight.
|
||||
const load = () =>
|
||||
(loading ??= driver.items(name).then((loaded) => {
|
||||
floor = loaded.revision
|
||||
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
|
||||
}
|
||||
for (const [key, value] of Object.entries(loaded.items)) {
|
||||
if (stale(key)) place(key, value, loaded.revision)
|
||||
}
|
||||
}))
|
||||
|
||||
const write = (key: string, value: string | null) => {
|
||||
if (value === null) cache.delete(key)
|
||||
else cache.set(key, value)
|
||||
local.set(key, { seq: ++seq, value })
|
||||
dirty.add(key)
|
||||
timer ??= setTimeout(() => void flush(), delay)
|
||||
}
|
||||
|
||||
// The host accepted this window's value for `key` at `revision`. A change from another window
|
||||
// that was held back meanwhile wins if the host applied it later than ours.
|
||||
const acknowledge = (key: string, revision: number) => {
|
||||
local.delete(key)
|
||||
const later = deferred.get(key)
|
||||
deferred.delete(key)
|
||||
if (later && later.revision > revision) return place(key, later.value, later.revision)
|
||||
applied.set(key, revision)
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
if (dirty.size > 0) {
|
||||
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
|
||||
dirty.clear()
|
||||
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
|
||||
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
|
||||
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
|
||||
const request = driver
|
||||
.update(name, insert, remove)
|
||||
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
|
||||
.catch((error: unknown) => {
|
||||
// Only a value nothing newer has replaced is worth retrying.
|
||||
batch.filter(current).forEach((entry) => dirty.add(entry.key))
|
||||
console.error(`[persistence] flush failed for ${name}`, error)
|
||||
})
|
||||
.finally(() => inflight.delete(request))
|
||||
inflight.add(request)
|
||||
}
|
||||
return Promise.all(inflight).then(() => undefined)
|
||||
}
|
||||
|
||||
const storage: NamespaceStorage = {
|
||||
getItem: async (key) => {
|
||||
await load()
|
||||
return cache.get(key) ?? null
|
||||
},
|
||||
setItem: async (key, value) => write(key, value),
|
||||
removeItem: async (key) => write(key, null),
|
||||
clear: async () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
cache.clear()
|
||||
local.clear()
|
||||
dirty.clear()
|
||||
applied.clear()
|
||||
deferred.clear()
|
||||
loading = Promise.resolve()
|
||||
await driver.clear(name)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
await load()
|
||||
return [...cache.keys()][index]
|
||||
},
|
||||
getLength: async () => {
|
||||
await load()
|
||||
return cache.size
|
||||
},
|
||||
get length() {
|
||||
return storage.getLength()
|
||||
},
|
||||
flush,
|
||||
accept(insert, remove, revision) {
|
||||
// The initial load already reflects everything up to `floor`.
|
||||
if (revision <= floor) return
|
||||
const changes = [
|
||||
...Object.entries(insert).map(([key, value]) => [key, value] as const),
|
||||
...remove.map((key) => [key, null] as const),
|
||||
]
|
||||
for (const [key, value] of changes) {
|
||||
if (local.has(key)) {
|
||||
const held = deferred.get(key)
|
||||
if (!held || revision > held.revision) deferred.set(key, { revision, value })
|
||||
continue
|
||||
}
|
||||
if (revision <= (applied.get(key) ?? floor)) continue
|
||||
place(key, value, revision)
|
||||
}
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { flushPersisted, persistStore } from "./persist"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type State = { count: number; label: string }
|
||||
|
||||
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
|
||||
const writes: string[] = []
|
||||
return createRoot((dispose) => {
|
||||
const [store, setStore] = createStore<State>({ count: 0, label: "" })
|
||||
const persist = persistStore({
|
||||
store,
|
||||
setStore,
|
||||
name: "state",
|
||||
// One fixture serves both the sync and the async storage shape.
|
||||
storage: {
|
||||
getItem: () => input.initial ?? null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value)
|
||||
},
|
||||
removeItem: () => {},
|
||||
} as SyncStorage | AsyncStorage,
|
||||
serialize: JSON.stringify,
|
||||
deserialize: JSON.parse,
|
||||
sync: input.sync,
|
||||
delay: input.delay ?? 10,
|
||||
})
|
||||
return { store, set: persist.setStore, persist, writes, dispose }
|
||||
})
|
||||
}
|
||||
|
||||
describe("persistStore", () => {
|
||||
test("marks the store dirty on set and writes once after the delay", async () => {
|
||||
const value = setup({})
|
||||
value.set("count", 1)
|
||||
value.set("count", 2)
|
||||
value.set("label", "a")
|
||||
expect(value.store.count).toBe(2)
|
||||
expect(value.writes).toEqual([])
|
||||
await wait(30)
|
||||
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("skips the write when the serialized value did not change", () => {
|
||||
const value = setup({ delay: 10_000 })
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("hydrates synchronously from sync storage without writing back", () => {
|
||||
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
|
||||
expect(value.store).toEqual({ count: 5, label: "saved" })
|
||||
value.persist.flush()
|
||||
expect(value.writes).toEqual([])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a set made while async storage loads wins over the loaded value", async () => {
|
||||
const loading = Promise.withResolvers<string | null>()
|
||||
const value = setup({ initial: loading.promise, delay: 10_000 })
|
||||
value.set("count", 9)
|
||||
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
|
||||
await loading.promise
|
||||
expect(value.store.count).toBe(9)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("applies another window's value when clean and ignores it while dirty", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const sent: string[] = []
|
||||
const value = setup({
|
||||
delay: 10_000,
|
||||
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
|
||||
})
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
|
||||
expect(value.store).toEqual({ count: 3, label: "remote" })
|
||||
value.set("label", "local")
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
|
||||
expect(value.store).toEqual({ count: 3, label: "local" })
|
||||
value.persist.flush()
|
||||
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const sent: string[] = []
|
||||
const value = setup({
|
||||
delay: 10_000,
|
||||
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
|
||||
})
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
// Setting the same value again marks the store dirty without changing it.
|
||||
value.set("count", 1)
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
|
||||
expect(value.store.label).toBe("")
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 1, label: "remote" })
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(sent).toHaveLength(1)
|
||||
// A later save must not consider the adopted value a local change.
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
|
||||
value.set("label", "saved")
|
||||
value.persist.flush()
|
||||
value.set("label", "saved")
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 0, label: "saved" })
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
|
||||
value.set("count", 1)
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 1, label: "" })
|
||||
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("disposing the owner and flushPersisted both save pending changes", () => {
|
||||
const first = setup({ delay: 10_000 })
|
||||
first.set("count", 1)
|
||||
first.dispose()
|
||||
expect(first.writes).toHaveLength(1)
|
||||
|
||||
const second = setup({ delay: 10_000 })
|
||||
second.set("count", 2)
|
||||
flushPersisted()
|
||||
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
|
||||
second.dispose()
|
||||
expect(second.writes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
|
||||
import { getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
|
||||
export const persistSaveDelay = 100
|
||||
|
||||
const pending = new Set<() => void>()
|
||||
|
||||
/** Serialize and write every store with unsaved changes now. */
|
||||
export function flushPersisted() {
|
||||
for (const save of [...pending]) save()
|
||||
}
|
||||
|
||||
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
|
||||
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushPersisted()
|
||||
})
|
||||
window.addEventListener("pagehide", flushPersisted)
|
||||
}
|
||||
|
||||
// A store whose serialized form is written to storage on a schedule instead of on every setter
|
||||
// call. The setter only marks the store dirty; serialization happens once per save window, once
|
||||
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
|
||||
export function persistStore<T extends object>(input: {
|
||||
store: Store<T>
|
||||
setStore: SetStoreFunction<T>
|
||||
name: string
|
||||
storage: SyncStorage | AsyncStorage
|
||||
serialize: (value: T) => string
|
||||
deserialize: (raw: string) => T
|
||||
sync?: PersistenceSyncAPI
|
||||
delay?: number
|
||||
}) {
|
||||
const delay = input.delay ?? persistSaveDelay
|
||||
let dirty = false
|
||||
let touched = false
|
||||
let last: string | undefined
|
||||
// The newest value another window wrote while this store was dirty; applied at save time if the
|
||||
// local setter calls turned out not to change anything.
|
||||
let remote: string | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const save = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
pending.delete(save)
|
||||
if (!dirty) return
|
||||
dirty = false
|
||||
const held = remote
|
||||
remote = undefined
|
||||
const next = untrack(() => input.serialize(input.store))
|
||||
if (next === last) {
|
||||
if (held !== undefined && held !== last) hydrate(held)
|
||||
return
|
||||
}
|
||||
last = next
|
||||
input.sync?.[1](input.name, next)
|
||||
void input.storage.setItem(input.name, next)
|
||||
}
|
||||
|
||||
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
|
||||
const apply = input.setStore as unknown as (...values: unknown[]) => void
|
||||
const setStore = ((...values: unknown[]) => {
|
||||
apply(...values)
|
||||
dirty = true
|
||||
touched = true
|
||||
pending.add(save)
|
||||
timer ??= setTimeout(save, delay)
|
||||
}) as unknown as SetStoreFunction<T>
|
||||
|
||||
const hydrate = (raw: string) => {
|
||||
last = raw
|
||||
input.setStore(reconcile(input.deserialize(raw)))
|
||||
}
|
||||
const init = input.storage.getItem(input.name)
|
||||
// A value the user already changed is newer than whatever storage held.
|
||||
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
|
||||
else if (init) hydrate(init)
|
||||
|
||||
input.sync?.[0]((data) => {
|
||||
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
|
||||
if (!data.newValue) return
|
||||
// A real unsaved local change wins over another window's write, as in VS Code's storage
|
||||
// service; whether the change is real is only known when the store is serialized. Every
|
||||
// remote value replaces the held one, including a revert to `last`, so the save sees the
|
||||
// other window's final state rather than an intermediate one.
|
||||
if (dirty) {
|
||||
remote = data.newValue
|
||||
return
|
||||
}
|
||||
if (data.newValue === last) return
|
||||
hydrate(data.newValue)
|
||||
})
|
||||
|
||||
if (getOwner()) onCleanup(save)
|
||||
|
||||
return { setStore, init, flush: save }
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Platform, usePlatform } from "@/runtime/platform/platform"
|
||||
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { persistStore } from "./persist"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
type InitType = Promise<string> | string | null
|
||||
type InitType = Promise<string | null> | string | null
|
||||
type PersistedWithReady<T> = [
|
||||
Store<T>,
|
||||
SetStoreFunction<T>,
|
||||
@@ -593,13 +594,18 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
: undefined
|
||||
if (channel) onCleanup(() => channel.close())
|
||||
|
||||
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
|
||||
const persist = persistStore({
|
||||
store: store[0],
|
||||
setStore: store[1],
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
})
|
||||
const state = store[0]
|
||||
const setState = persist.setStore
|
||||
const init = persist.init
|
||||
|
||||
const isAsync = init instanceof Promise
|
||||
const [ready] = createResource(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
import { createRefCountMap } from "@/runtime/server/refcount"
|
||||
import { createRequestQueue } from "@/runtime/server/request-queue"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "./current"
|
||||
|
||||
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
const api = createApiForServer({ server: http, fetch: queue.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
@@ -20,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
@@ -47,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
|
||||
expect(store.project[0]?.worktrees).toEqual([
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
@@ -76,70 +88,39 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads each project's inventory through its own location using the real client", async () => {
|
||||
const calls: string[] = []
|
||||
test("loads project metadata without enumerating any project's worktrees", async () => {
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
|
||||
calls.push(directory)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
|
||||
return Response.json([
|
||||
{ directory },
|
||||
{ directory: `${directory}/clone` },
|
||||
{ directory: `${directory}/copy`, strategy: "git" },
|
||||
...Array.from({ length: 300 }, (_, index) => ({
|
||||
id: `historical-${index.toString().padStart(3, "0")}`,
|
||||
canonical: `/history/${index}`,
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
|
||||
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
["/a/clone", "/a/copy"],
|
||||
["/b/clone", "/b/copy"],
|
||||
])
|
||||
expect(result.map((project) => project.worktrees)).toEqual([
|
||||
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
||||
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
||||
])
|
||||
expect(calls.toSorted()).toEqual(["/a", "/b"])
|
||||
})
|
||||
|
||||
test("keeps projects whose directory inventory cannot load", async () => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
|
||||
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
|
||||
return Response.json([{ directory: "/a/copy", strategy: "git" }])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
|
||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||
{ id: "a", sandboxes: ["/a/copy"] },
|
||||
{ id: "b", sandboxes: [] },
|
||||
expect(requests).toEqual(["/api/project"])
|
||||
expect(result).toHaveLength(302)
|
||||
expect(result.slice(0, 2)).toMatchObject([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
|
||||
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
|
||||
])
|
||||
expect(result.some((project) => project.id === "test")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -64,42 +63,21 @@ type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
projects.list().then(async (items) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(async (project) => {
|
||||
const directories = await worktrees
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.catch(() => [
|
||||
{ directory: project.canonical },
|
||||
...(project.sandboxes ?? [])
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory))
|
||||
.map((directory) => ({ directory })),
|
||||
])
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
||||
worktrees: directories,
|
||||
})
|
||||
}),
|
||||
)
|
||||
)
|
||||
projects.list().then((items) =>
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
}
|
||||
scope: ServerScope
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
|
||||
input.setGlobalStore(
|
||||
"project",
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
await runAll(slow)
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
|
||||
disposers.set(key, dispose)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
void init.then(() => {
|
||||
if (children[key] !== child) return
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue, isSetupRequest, isSlowRequest } from "./request-queue"
|
||||
|
||||
function setup(input?: {
|
||||
limit?: number
|
||||
slowLimit?: number
|
||||
stallMs?: number
|
||||
headersTimeoutMs?: number
|
||||
setupHeadersTimeoutMs?: number
|
||||
}) {
|
||||
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
slowLimit: input?.slowLimit,
|
||||
stallMs: input?.stallMs,
|
||||
headersTimeoutMs: input?.headersTimeoutMs,
|
||||
setupHeadersTimeoutMs: input?.setupHeadersTimeoutMs,
|
||||
now: () => clock,
|
||||
log: (message, data) => logs.push({ message, data }),
|
||||
fetch: Object.assign(
|
||||
(resource: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((resolve, reject) => {
|
||||
const request = new Request(resource, init)
|
||||
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
|
||||
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
|
||||
}),
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
|
||||
const input = setup({ limit: 4, slowLimit: 2 })
|
||||
const paths = [
|
||||
"/api/vcs?location[directory]=%2Fa",
|
||||
"/api/vcs/diff?location[directory]=%2Fa",
|
||||
"/api/worktree",
|
||||
"/api/session/ses_1",
|
||||
]
|
||||
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
const started = () => input.pending.map((item) => new URL(item.url).pathname)
|
||||
// Two slow requests fill the slow share; the worktree read waits while the session read jumps ahead.
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
|
||||
expect(input.queue.inflight()).toBe(3)
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
// A fast request finishing does not free a slow slot.
|
||||
input.pending[2]!.resolve()
|
||||
await input.settle()
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1", "/api/worktree"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("classifies git and worktree endpoints as slow", () => {
|
||||
expect(isSlowRequest("/api/vcs")).toBe(true)
|
||||
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
|
||||
expect(isSlowRequest("/api/worktree")).toBe(true)
|
||||
expect(isSlowRequest("/api/vcsx")).toBe(false)
|
||||
expect(isSlowRequest("/api/session")).toBe(false)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
void input.queue.fetch("http://server/api/event")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
})
|
||||
|
||||
test("aborted requests leave the queue without being sent", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
void input.queue.fetch("http://server/api/first")
|
||||
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
|
||||
controller.abort()
|
||||
await input.settle()
|
||||
input.pending[0]!.resolve()
|
||||
await expect(aborted).rejects.toBeInstanceOf(DOMException)
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("a request the server never answers times out and frees its slot", async () => {
|
||||
const input = setup({ limit: 1, headersTimeoutMs: 10 })
|
||||
const dead = input.queue.fetch("http://server/api/dead")
|
||||
const next = input.queue.fetch("http://server/api/next")
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
const error = await dead.catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(DOMException)
|
||||
expect((error as DOMException).name).toBe("TimeoutError")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
|
||||
input.pending[1]!.resolve()
|
||||
await expect(next).resolves.toBeInstanceOf(Response)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("worktree creation gets the setup deadline while worktree reads keep the normal one", async () => {
|
||||
const input = setup({ limit: 4, headersTimeoutMs: 10, setupHeadersTimeoutMs: 200 })
|
||||
const create = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa", { method: "POST" })
|
||||
const list = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa")
|
||||
const listError = await list.catch((cause: unknown) => cause)
|
||||
expect((listError as DOMException).name).toBe("TimeoutError")
|
||||
// Past the normal deadline, the create is still on the wire.
|
||||
expect(input.pending[0]!.signal.aborted).toBe(false)
|
||||
input.pending[0]!.resolve()
|
||||
await expect(create).resolves.toBeInstanceOf(Response)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("only worktree creation counts as a setup request", () => {
|
||||
expect(isSetupRequest("POST", "/api/worktree")).toBe(true)
|
||||
expect(isSetupRequest("GET", "/api/worktree")).toBe(false)
|
||||
expect(isSetupRequest("POST", "/api/worktree/refresh")).toBe(false)
|
||||
expect(isSetupRequest("DELETE", "/api/worktree")).toBe(false)
|
||||
})
|
||||
|
||||
test("caller aborts still reach the underlying request", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
const request = input.queue.fetch("http://server/api/slow", { signal: controller.signal })
|
||||
await input.settle()
|
||||
expect(input.pending[0]!.signal.aborted).toBe(false)
|
||||
controller.abort()
|
||||
expect(input.pending[0]!.signal.aborted).toBe(true)
|
||||
await expect(request).rejects.toBeInstanceOf(DOMException)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("a burst that drains promptly is not thrashing", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(10)
|
||||
// Drain two at a time before the stall threshold elapses.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
input.pending.splice(0).forEach((item) => item.resolve())
|
||||
await input.settle()
|
||||
}
|
||||
await Promise.all(responses)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("logs what is in flight and queued once per burst after requests stall", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
|
||||
input.tick(100)
|
||||
input.queue.fetch("http://server/api/health").catch(() => undefined)
|
||||
expect(input.logs).toEqual([])
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([
|
||||
{
|
||||
message: "server thrashing detected",
|
||||
data: {
|
||||
limit: 2,
|
||||
inflight: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
|
||||
],
|
||||
queued: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
|
||||
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
// Still stalled within the rate limit: no repeat.
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(1)
|
||||
input.tick(10_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
type Entry = { method: string; url: string; at: number; slow: boolean }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// Endpoints that shell out to git or walk the filesystem take seconds on a large repository. They
|
||||
// may hold at most this many slots, so a session mount's small reads never queue behind them.
|
||||
export const requestQueueSlowLimit = 2
|
||||
export const slowRequestPaths = ["/api/vcs", "/api/worktree"]
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
|
||||
// A socket that dies while the device sleeps can leave fetch waiting for response headers until the
|
||||
// OS gives up on TCP retransmits, which takes minutes. Bound that so a dead request frees its slot
|
||||
// instead of wedging every later API call; the body may still stream for as long as it needs.
|
||||
export const requestHeadersTimeoutMs = 60_000
|
||||
|
||||
// Creating a worktree runs the project's setup script (dependency installs, fetches) before the
|
||||
// server answers, so it needs a budget measured in minutes rather than seconds. Cutting it off
|
||||
// kills the script midway and leaves a registered but half-initialised worktree behind.
|
||||
export const setupRequestHeadersTimeoutMs = 10 * 60_000
|
||||
|
||||
export function isSlowRequest(pathname: string) {
|
||||
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
export function isSetupRequest(method: string, pathname: string) {
|
||||
return method === "POST" && pathname === "/api/worktree"
|
||||
}
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
slowLimit?: number
|
||||
stallMs?: number
|
||||
headersTimeoutMs?: number
|
||||
setupHeadersTimeoutMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
|
||||
const setupHeadersTimeoutMs = input.setupHeadersTimeoutMs ?? setupRequestHeadersTimeoutMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
const base = input.fetch
|
||||
const now = input.now ?? Date.now
|
||||
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
|
||||
const inflight = new Set<Entry>()
|
||||
const waiting: Array<{ entry: Entry; start: () => void }> = []
|
||||
let warned = -Infinity
|
||||
let watcher: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
|
||||
// Debug exports include the console, so list what the server is busy with while requests wait.
|
||||
const watch = () => {
|
||||
watcher = undefined
|
||||
const oldest = waiting[0]?.entry
|
||||
if (!oldest) return
|
||||
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
|
||||
warned = now()
|
||||
log("server thrashing detected", {
|
||||
limit,
|
||||
inflight: [...inflight].map(describe),
|
||||
queued: waiting.map((item) => describe(item.entry)),
|
||||
})
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const canStart = (entry: Entry) => {
|
||||
if (inflight.size >= limit) return false
|
||||
if (!entry.slow) return true
|
||||
return [...inflight].filter((item) => item.slow).length < slowLimit
|
||||
}
|
||||
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
const index = waiting.findIndex((item) => canStart(item.entry))
|
||||
if (index === -1) return
|
||||
waiting.splice(index, 1)[0]?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (canStart(entry)) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
const pathname = new URL(request.url).pathname
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
}
|
||||
const controller = new AbortController()
|
||||
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
|
||||
isSetupRequest(request.method, pathname) ? setupHeadersTimeoutMs : headersTimeoutMs,
|
||||
)
|
||||
return base(new Request(request, { signal: controller.signal })).finally(() => {
|
||||
clearTimeout(timer)
|
||||
release(entry)
|
||||
})
|
||||
},
|
||||
// Bun's fetch type carries preconnect; the browser never calls it.
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
|
||||
return {
|
||||
fetch,
|
||||
inflight: () => inflight.size,
|
||||
queued: () => waiting.length,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createConnectionSync } from "./connection"
|
||||
import { createConnectionSync, reconnectOrder } from "./connection"
|
||||
|
||||
test("invalidates disconnected data and synchronizes after the handshake", () => {
|
||||
const calls: string[] = []
|
||||
@@ -19,3 +19,9 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
test("held directories refresh before the rest, otherwise keeping their order", () => {
|
||||
const held = new Set(["/b", "/d"])
|
||||
expect(reconnectOrder(["/a", "/b", "/c", "/d"], (directory) => held.has(directory))).toEqual(["/b", "/d", "/a", "/c"])
|
||||
expect(reconnectOrder(["/a", "/c"], (directory) => held.has(directory))).toEqual(["/a", "/c"])
|
||||
})
|
||||
|
||||
@@ -20,3 +20,8 @@ export function createConnectionSync(input: {
|
||||
|
||||
return { handleEvent }
|
||||
}
|
||||
|
||||
// Directories a mounted view holds refresh first; the rest keep their existing order behind them.
|
||||
export function reconnectOrder(directories: string[], held: (directory: string) => boolean) {
|
||||
return [...directories.filter(held), ...directories.filter((directory) => !held(directory))]
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const worktrees = createWorktreeInventory({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
),
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
@@ -149,12 +162,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
},
|
||||
connected: (info) => {
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
Object.keys(children.children)
|
||||
.filter(children.active)
|
||||
.forEach((directory) => {
|
||||
queue.push(directory)
|
||||
void data.location.sync({ directory }).catch(() => undefined)
|
||||
})
|
||||
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
|
||||
// directory here as well sent the whole catalog fan-out for all of them at once.
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
|
||||
(directory) => queue.push(directory),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -197,17 +209,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
: project,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,7 +238,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
@@ -261,6 +282,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
worktrees,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
|
||||
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
|
||||
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
type SessionComposerRegionState = Pick<
|
||||
SessionComposerRegionController["state"],
|
||||
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
|
||||
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
|
||||
>
|
||||
|
||||
export type SessionComposerRegionViewController = Pick<
|
||||
@@ -32,6 +33,9 @@ export function SessionComposerRegion(props: {
|
||||
"md:max-w-[1000px] md:mx-auto": controller.centered(),
|
||||
}}
|
||||
>
|
||||
<Show when={controller.state.websearch.request()}>
|
||||
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
|
||||
</Show>
|
||||
<Show when={controller.state.questionRequest()} keyed>
|
||||
{(request) => (
|
||||
<div>
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type FileTreeV2Node,
|
||||
} from "@/session/files/file-tree-v2-model"
|
||||
import { virtualScrollElement } from "@/session/files/virtual-scroll"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useOpenInApp } from "@/session/files/open-in-app"
|
||||
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
|
||||
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export type { Kind } from "@/session/files/file-tree"
|
||||
|
||||
@@ -99,7 +104,7 @@ const FileTreeNodeV2 = (
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
|
||||
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
|
||||
<bdi dir="auto">
|
||||
{local.node.type === "directory"
|
||||
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
|
||||
@@ -136,6 +141,9 @@ export default function FileTreeV2(props: {
|
||||
onFileDoubleClick?: (file: FileNode) => void
|
||||
}) {
|
||||
const file = useFile()
|
||||
const location = useWorkspaceLocation()
|
||||
const platform = usePlatform()
|
||||
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
|
||||
const live = () => props.allowed === undefined
|
||||
const draggable = () => props.draggable ?? true
|
||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||
@@ -217,6 +225,19 @@ export default function FileTreeV2(props: {
|
||||
)
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
||||
|
||||
createEffect(() => {
|
||||
rows()
|
||||
const element = root()
|
||||
if (!element) return
|
||||
element.style.removeProperty("width")
|
||||
syncFileTreeV2Width(element)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
virtualRowKeys()
|
||||
syncFileTreeV2Width(root())
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRoot}
|
||||
@@ -235,6 +256,7 @@ export default function FileTreeV2(props: {
|
||||
top: "0",
|
||||
"inset-inline-start": "0",
|
||||
width: "100%",
|
||||
"min-width": "max-content",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
}}
|
||||
@@ -244,29 +266,36 @@ export default function FileTreeV2(props: {
|
||||
<Show
|
||||
when={row().node.type === "directory"}
|
||||
fallback={
|
||||
<FileTreeNodeV2
|
||||
node={row().node}
|
||||
level={row().level}
|
||||
active={active()}
|
||||
draggable={draggable()}
|
||||
kinds={props.kinds}
|
||||
as="button"
|
||||
type="button"
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() => selectFile(row().node, props.onFileClick)}
|
||||
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
|
||||
<OpenInAppContextMenuV2
|
||||
state={openIn}
|
||||
path={() =>
|
||||
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
|
||||
}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<Show when={row().level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</FileTreeNodeV2>
|
||||
<FileTreeNodeV2
|
||||
node={row().node}
|
||||
level={row().level}
|
||||
active={active()}
|
||||
draggable={draggable()}
|
||||
kinds={props.kinds}
|
||||
as="button"
|
||||
type="button"
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() => selectFile(row().node, props.onFileClick)}
|
||||
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<Show when={row().level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</FileTreeNodeV2>
|
||||
</OpenInAppContextMenuV2>
|
||||
}
|
||||
>
|
||||
<FileTreeNodeV2
|
||||
@@ -303,3 +332,13 @@ export default function FileTreeV2(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function syncFileTreeV2Width(element?: HTMLDivElement) {
|
||||
if (!element) return
|
||||
queueMicrotask(() => {
|
||||
if (!element.isConnected) return
|
||||
const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth))
|
||||
if (width <= element.clientWidth) return
|
||||
element.style.width = `${width}px`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/file-tree.css"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2"
|
||||
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
|
||||
import { normalizePath } from "@/session/review/review-diff-kinds"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { virtualScrollElement } from "@/session/files/virtual-scroll"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useOpenInApp } from "@/session/files/open-in-app"
|
||||
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
|
||||
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
// Drives the highlight/selection of the flat search-result list from the filter
|
||||
// input's keyboard events.
|
||||
@@ -49,6 +54,9 @@ export function SessionFileList(props: {
|
||||
onFileClick: (path: string) => void
|
||||
onFileDoubleClick?: (path: string) => void
|
||||
}) {
|
||||
const location = useWorkspaceLocation()
|
||||
const platform = usePlatform()
|
||||
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
|
||||
const active = () => normalizePath(props.active ?? "")
|
||||
const highlighted = () => normalizePath(props.highlighted ?? "")
|
||||
const normalized = createMemo(() => props.files.map(normalizePath))
|
||||
@@ -89,6 +97,19 @@ export function SessionFileList(props: {
|
||||
)
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
||||
|
||||
createEffect(() => {
|
||||
normalized()
|
||||
const element = root()
|
||||
if (!element) return
|
||||
element.style.removeProperty("width")
|
||||
syncFileTreeV2Width(element)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
virtualRowKeys()
|
||||
syncFileTreeV2Width(root())
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRoot}
|
||||
@@ -114,47 +135,48 @@ export function SessionFileList(props: {
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
"inset-inline-start": "0",
|
||||
width: "100%",
|
||||
"min-width": "max-content",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={props.optionID?.(path)}
|
||||
role={props.role ? "option" : undefined}
|
||||
aria-selected={props.role ? selected() : undefined}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-path={path}
|
||||
data-selected={selected() ? "" : undefined}
|
||||
data-highlighted={highlightedRow() ? "" : undefined}
|
||||
style="padding-left: 8px"
|
||||
onFocus={() => setFocused(path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() => props.onFileClick(path)}
|
||||
onDblClick={() => props.onFileDoubleClick?.(path)}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
|
||||
<Show when={directory()}>
|
||||
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
|
||||
<button
|
||||
type="button"
|
||||
id={props.optionID?.(path)}
|
||||
role={props.role ? "option" : undefined}
|
||||
aria-selected={props.role ? selected() : undefined}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-path={path}
|
||||
data-selected={selected() ? "" : undefined}
|
||||
data-highlighted={highlightedRow() ? "" : undefined}
|
||||
style="padding-inline-start: 8px"
|
||||
onFocus={() => setFocused(path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() => props.onFileClick(path)}
|
||||
onDblClick={() => props.onFileDoubleClick?.(path)}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
|
||||
<Show when={directory()}>
|
||||
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
|
||||
</Show>
|
||||
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
|
||||
</span>
|
||||
<Show when={kind()}>
|
||||
{(value) => (
|
||||
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
|
||||
{kindLabel(value())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
|
||||
</span>
|
||||
<Show when={kind()}>
|
||||
{(value) => (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
|
||||
{kindLabel(value())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</button>
|
||||
</OpenInAppContextMenuV2>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { createSignal, For, Show, type ParentProps } from "solid-js"
|
||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
|
||||
|
||||
export function OpenInAppButton(props: { directory: () => string }) {
|
||||
const language = useLanguage()
|
||||
const state = useOpenInApp(props)
|
||||
const state = useOpenInApp({ path: props.directory })
|
||||
|
||||
return (
|
||||
<Show when={props.directory() && state.canOpen()}>
|
||||
@@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (state.opening()) return
|
||||
state.openDir(state.current().id)
|
||||
state.openPath(state.current().id)
|
||||
}}
|
||||
disabled={state.opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
|
||||
@@ -52,42 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="open-in-app-v2-menu">
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
value={state.current().id}
|
||||
onChange={(value) => {
|
||||
state.selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={state.options()}>
|
||||
{(option) => (
|
||||
<Menu.RadioItem
|
||||
value={option.id}
|
||||
disabled={state.opening()}
|
||||
onSelect={() => {
|
||||
state.selectApp(option.id)
|
||||
state.setMenu("open", false)
|
||||
state.openDir(option.id)
|
||||
}}
|
||||
>
|
||||
<AppIcon id={option.icon} />
|
||||
{option.label}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
state.setMenu("open", false)
|
||||
state.copyPath()
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</Menu.Item>
|
||||
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
@@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
type OpenInAppState = ReturnType<typeof useOpenInApp>
|
||||
|
||||
function OpenInAppMenuItemsV2(props: {
|
||||
state: OpenInAppState
|
||||
path?: () => string
|
||||
reveal?: boolean
|
||||
selection?: boolean
|
||||
close?: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const path = () => props.path?.()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
|
||||
<Show
|
||||
when={props.selection !== false}
|
||||
fallback={
|
||||
<For each={props.state.options()}>
|
||||
{(option) => (
|
||||
<Menu.Item
|
||||
disabled={props.state.opening()}
|
||||
onSelect={() => {
|
||||
props.state.selectApp(option.id)
|
||||
props.close?.()
|
||||
props.state.openPath(option.id, path(), props.reveal)
|
||||
}}
|
||||
>
|
||||
<AppIcon id={option.icon} />
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<Menu.RadioGroup
|
||||
value={props.state.current().id}
|
||||
onChange={(value) => {
|
||||
props.state.selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={props.state.options()}>
|
||||
{(option) => (
|
||||
<Menu.RadioItem
|
||||
value={option.id}
|
||||
closeOnSelect
|
||||
disabled={props.state.opening()}
|
||||
onSelect={() => {
|
||||
props.state.selectApp(option.id)
|
||||
props.close?.()
|
||||
props.state.openPath(option.id, path(), props.reveal)
|
||||
}}
|
||||
>
|
||||
<AppIcon id={option.icon} />
|
||||
{option.label}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
props.close?.()
|
||||
props.state.copyPath(path())
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenInAppContextMenuV2(
|
||||
props: ParentProps<{
|
||||
state?: OpenInAppState
|
||||
path: () => string
|
||||
}>,
|
||||
) {
|
||||
const state = props.state
|
||||
if (!state) return props.children
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Show when={state.canOpen() && props.path()} fallback={props.children}>
|
||||
<Menu.Context modal={false} onOpenChange={setOpen}>
|
||||
<Menu.Context.Trigger
|
||||
as="div"
|
||||
class="h-full w-full min-w-max"
|
||||
data-slot="file-tree-v2-context-trigger"
|
||||
data-context-menu-open={open() ? "" : undefined}
|
||||
>
|
||||
{props.children}
|
||||
</Menu.Context.Trigger>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content class="open-in-app-v2-menu">
|
||||
<OpenInAppMenuItemsV2
|
||||
state={state}
|
||||
path={props.path}
|
||||
reveal
|
||||
selection={false}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path"
|
||||
|
||||
describe("resolveOpenInAppPath", () => {
|
||||
test("joins relative paths using the workspace separator", () => {
|
||||
expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts")
|
||||
expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts")
|
||||
})
|
||||
|
||||
test("does not duplicate root separators", () => {
|
||||
expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts")
|
||||
expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts")
|
||||
})
|
||||
|
||||
test("preserves backslashes in POSIX filenames", () => {
|
||||
expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts")
|
||||
expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts")
|
||||
})
|
||||
|
||||
test("preserves absolute POSIX, Windows, and UNC paths", () => {
|
||||
expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts")
|
||||
expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts")
|
||||
expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts")
|
||||
expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("openInAppParentPath", () => {
|
||||
test("preserves POSIX and Windows roots", () => {
|
||||
expect(openInAppParentPath("/file.ts")).toBe("/")
|
||||
expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace")
|
||||
expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\")
|
||||
expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace")
|
||||
expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
export function resolveOpenInAppPath(root: string, path: string) {
|
||||
if (!path) return root
|
||||
const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root)
|
||||
if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path
|
||||
if (!root) return path
|
||||
|
||||
const separator = root.includes("\\") ? "\\" : "/"
|
||||
const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path
|
||||
return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}`
|
||||
}
|
||||
|
||||
export function openInAppParentPath(path: string) {
|
||||
const value = path.replace(/[\\/]+$/, "")
|
||||
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
|
||||
if (index < 0) return path
|
||||
if (index === 0) return value.slice(0, 1)
|
||||
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
|
||||
return value.slice(0, index)
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { openInAppParentPath } from "@/session/files/open-in-app-path"
|
||||
|
||||
export const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -32,6 +34,8 @@ export const OpenAppPreferences = Persistence.struct({
|
||||
app: Schema.Literals(OPEN_APPS),
|
||||
})
|
||||
|
||||
const appExistence = new Map<string, Promise<boolean>>()
|
||||
|
||||
export const MAC_OPEN_APPS = [
|
||||
{
|
||||
id: "vscode",
|
||||
@@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenA
|
||||
}
|
||||
|
||||
export function openAppFileManager(os: OpenAppOS) {
|
||||
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||
return fileManagerApp(os)
|
||||
}
|
||||
|
||||
export function openAppsForOS(os: OpenAppOS) {
|
||||
@@ -127,7 +129,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpenInApp(input: { directory: () => string }) {
|
||||
export function useOpenInApp(input: { path: () => string }) {
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
@@ -149,12 +151,7 @@ export function useOpenInApp(input: { directory: () => string }) {
|
||||
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
||||
|
||||
void Promise.all(
|
||||
list.map((app) =>
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => [app.id, ok] as const),
|
||||
),
|
||||
list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
})
|
||||
@@ -189,33 +186,35 @@ export function useOpenInApp(input: { directory: () => string }) {
|
||||
setPrefs("app", app)
|
||||
}
|
||||
|
||||
const openDir = (app: OpenApp | "finder") => {
|
||||
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
|
||||
if (opening() || !canOpen() || !platform.openPath) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
if (!target) return
|
||||
|
||||
const open = (path: string, openWith?: string) => platform.openPath!(path, openWith)
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
setOpenRequest("app", app)
|
||||
platform
|
||||
.openPath(directory, openWith)
|
||||
const request =
|
||||
app === "finder" && reveal && platform.revealPath
|
||||
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
|
||||
: open(target, openWith)
|
||||
request
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
.finally(() => {
|
||||
setOpenRequest("app", undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const copyPath = () => {
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
const copyPath = (target = input.path()) => {
|
||||
if (!target) return
|
||||
navigator.clipboard
|
||||
.writeText(directory)
|
||||
.writeText(target)
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("common.copied"),
|
||||
description: directory,
|
||||
description: target,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => showRequestError(language, err))
|
||||
@@ -228,8 +227,18 @@ export function useOpenInApp(input: { directory: () => string }) {
|
||||
options,
|
||||
menu,
|
||||
setMenu,
|
||||
openDir,
|
||||
openPath,
|
||||
selectApp,
|
||||
copyPath,
|
||||
}
|
||||
}
|
||||
|
||||
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
|
||||
const cached = appExistence.get(app)
|
||||
if (cached) return cached
|
||||
const request = Promise.resolve(platform.checkAppExists?.(app))
|
||||
.then(Boolean)
|
||||
.catch(() => false)
|
||||
appExistence.set(app, request)
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
|
||||
import { createWebSearchRequest } from "./websearch"
|
||||
import { createSessionBackground } from "@/session/requests/background"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
|
||||
@@ -24,12 +25,40 @@ export function createSessionRequestModel() {
|
||||
void Promise.all([
|
||||
data.shell.sync({ directory: sdk().directory }),
|
||||
data.session.permission.sync(id),
|
||||
data.session.form.sync(id),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
void Promise.all(
|
||||
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
|
||||
).catch(() => undefined)
|
||||
})
|
||||
|
||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
|
||||
const formRequest = createMemo((): FormInfo | undefined => {
|
||||
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
|
||||
})
|
||||
const websearch = createWebSearchRequest({
|
||||
owner: () => params.id,
|
||||
connected: () => serverSDK.connection.status() === "connected",
|
||||
request: () => {
|
||||
const form = formRequest()
|
||||
return form?.metadata?.kind === "websearch.provider" ? form : undefined
|
||||
},
|
||||
providers: async (sessionID) => {
|
||||
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
|
||||
const result = await serverSDK.api.websearch.providers({
|
||||
location: { directory: session.location.directory, workspace: session.location.workspaceID },
|
||||
})
|
||||
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
|
||||
},
|
||||
reply: (input) => data.session.form.reply(input),
|
||||
events: serverSDK.event,
|
||||
})
|
||||
const questionRequest = createMemo(() => {
|
||||
if (websearch.request()) return
|
||||
const form = formRequest()
|
||||
return form?.metadata?.kind === "question" ? form : undefined
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
@@ -40,7 +69,7 @@ export function createSessionRequestModel() {
|
||||
const blocked = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
|
||||
})
|
||||
|
||||
const primary = () => {
|
||||
@@ -96,6 +125,7 @@ export function createSessionRequestModel() {
|
||||
return {
|
||||
blocked,
|
||||
questionRequest,
|
||||
websearch,
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
background: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
@@ -23,6 +23,22 @@ const question = (id: string, sessionID: string) =>
|
||||
fields: [{ key: "q0", type: "string" }],
|
||||
}) as FormInfo
|
||||
|
||||
describe("sessionTreeIDs", () => {
|
||||
test("returns only the current session and its descendants", () => {
|
||||
const sessions = [
|
||||
session({ id: "root" }),
|
||||
session({ id: "child", parentID: "root" }),
|
||||
session({ id: "grand", parentID: "child" }),
|
||||
session({ id: "sibling", parentID: "root" }),
|
||||
session({ id: "other" }),
|
||||
]
|
||||
|
||||
expect(sessionTreeIDs(sessions, "child")).toEqual(["child", "grand"])
|
||||
expect(sessionTreeIDs(sessions, "root")).toEqual(["root", "child", "sibling", "grand"])
|
||||
expect(sessionTreeIDs(sessions)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionPermissionRequest", () => {
|
||||
test("prefers the current session permission", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
@@ -81,7 +97,7 @@ describe("sessionPermissionRequest", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionQuestionForm", () => {
|
||||
describe("sessionFormRequest", () => {
|
||||
test("prefers the current session question", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const questions = {
|
||||
@@ -89,7 +105,7 @@ describe("sessionQuestionForm", () => {
|
||||
child: [question("q-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
|
||||
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-root")
|
||||
})
|
||||
|
||||
test("returns a nested child question", () => {
|
||||
@@ -102,15 +118,29 @@ describe("sessionQuestionForm", () => {
|
||||
grand: [question("q-grand", "grand")],
|
||||
}
|
||||
|
||||
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
})
|
||||
|
||||
test("skips forms that are not questions", () => {
|
||||
test("skips unsupported forms", () => {
|
||||
const sessions = [session({ id: "root" })]
|
||||
const forms = {
|
||||
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
|
||||
}
|
||||
|
||||
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
|
||||
expect(sessionFormRequest(sessions, forms, "root")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("finds web search consent in a nested child session", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const form = { ...question("search", "child"), metadata: { kind: "websearch.provider" } }
|
||||
expect(sessionFormRequest(sessions, { child: [form] }, "root")).toBe(form)
|
||||
})
|
||||
|
||||
test("preserves request order across questions and web search", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const form = { ...question("search", "root"), metadata: { kind: "websearch.provider" } }
|
||||
expect(sessionFormRequest(sessions, { root: [form, question("q", "root")] }, "root")).toBe(form)
|
||||
expect(sessionFormRequest(sessions, { root: [question("q", "root"), form] }, "root")?.id).toBe("q")
|
||||
expect(sessionFormRequest(sessions, { root: [form], child: [question("q", "child")] }, "root")).toBe(form)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,16 @@ function sessionTreeRequest<T>(
|
||||
sessionID?: string,
|
||||
include: (item: T) => boolean = () => true,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
const ids = sessionTreeIDs(session, sessionID)
|
||||
if (!ids.length) return
|
||||
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
|
||||
const id = ids.find((id) => list(id)?.some(include))
|
||||
if (!id) return
|
||||
return list(id)?.find(include)
|
||||
}
|
||||
|
||||
export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
|
||||
if (!sessionID) return []
|
||||
const map = session.reduce((acc, item) => {
|
||||
if (!item.parentID) return acc
|
||||
const list = acc.get(item.parentID)
|
||||
@@ -27,11 +35,7 @@ function sessionTreeRequest<T>(
|
||||
ids.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
|
||||
const id = ids.find((id) => list(id)?.some(include))
|
||||
if (!id) return
|
||||
return list(id)?.find(include)
|
||||
return ids
|
||||
}
|
||||
|
||||
export function sessionPermissionRequest(
|
||||
@@ -43,10 +47,15 @@ export function sessionPermissionRequest(
|
||||
return sessionTreeRequest(session, request, sessionID, include)
|
||||
}
|
||||
|
||||
export function sessionQuestionForm(
|
||||
export function sessionFormRequest(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
|
||||
sessionID?: string,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
||||
return sessionTreeRequest(
|
||||
session,
|
||||
request,
|
||||
sessionID,
|
||||
(item) => item.metadata?.kind === "question" || item.metadata?.kind === "websearch.provider",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
[data-component="session-websearch-dock"] {
|
||||
width: 100%;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 12px;
|
||||
|
||||
.websearch-body {
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
.websearch-setting {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.websearch-setting [data-component="settings-row"] {
|
||||
column-gap: 24px;
|
||||
padding-block: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.websearch-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 24px 16px 12px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.websearch-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-inline-end: auto;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.websearch-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.websearch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="menu-v2-content"][data-slot="select-v2-content"].websearch-provider-menu {
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
|
||||
[data-component="menu-v2-item"] {
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import type { WebSearchRequestModel } from "./websearch"
|
||||
import "./session-websearch-dock.css"
|
||||
|
||||
export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSubmit: () => void }) {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() => [
|
||||
...(props.model.specific() ? [] : [{ value: "random", label: language.t("session.websearch.any") }]),
|
||||
...props.model.options(),
|
||||
])
|
||||
const current = createMemo(() => options().find((option) => option.value === props.model.selected()))
|
||||
const busy = () => props.model.sending() || !props.model.connected()
|
||||
const status = () => {
|
||||
if (props.model.loading()) return language.t("common.loading")
|
||||
if (props.model.failed()) return language.t("session.websearch.failed")
|
||||
if (!props.model.options().length) return language.t("session.websearch.empty")
|
||||
}
|
||||
const unavailable = () => props.model.loading() || props.model.loadFailed() || !props.model.options().length
|
||||
const submit = (selection: string | false) => {
|
||||
if (busy()) return
|
||||
props.onSubmit()
|
||||
void props.model.submit(selection)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-component="session-websearch-dock"
|
||||
aria-label={language.t("session.websearch.title")}
|
||||
aria-busy={props.model.sending()}
|
||||
>
|
||||
<DockShell class="websearch-body">
|
||||
<div class="websearch-setting">
|
||||
<SettingsRow
|
||||
title={language.t("session.websearch.title")}
|
||||
description={language.t("session.websearch.description")}
|
||||
>
|
||||
<Select
|
||||
aria-label={language.t("session.websearch.provider")}
|
||||
options={options()}
|
||||
current={current()}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
onSelect={(option) => option && props.model.select(option.value)}
|
||||
disabled={busy() || unavailable()}
|
||||
placeholder={language.t("session.websearch.provider")}
|
||||
contentClass="websearch-provider-menu"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray attach="top" class="websearch-footer">
|
||||
<div class="websearch-status" aria-live="polite">
|
||||
<Show when={props.model.loadFailed()} fallback={status()}>
|
||||
<span>{language.t("session.websearch.loadFailed")}</span>
|
||||
<Button variant="ghost" size="small" onClick={props.model.retry} disabled={busy()}>
|
||||
{language.t("session.websearch.retry")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="websearch-actions">
|
||||
<Show when={!props.model.specific()}>
|
||||
<Button variant="ghost" size="small" onClick={() => submit(false)} disabled={busy()}>
|
||||
{language.t("session.websearch.disable")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const selected = props.model.selected()
|
||||
if (selected) submit(selected)
|
||||
}}
|
||||
disabled={busy() || unavailable() || !current()}
|
||||
>
|
||||
{language.t("session.websearch.enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</DockTray>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { replyWebSearch } from "./websearch"
|
||||
|
||||
const consent: FormCreated["data"]["form"] = {
|
||||
id: "frm_consent",
|
||||
sessionID: "ses_child",
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [{ key: "choice", type: "string", required: true, custom: false }],
|
||||
}
|
||||
const provider: FormCreated["data"]["form"] = {
|
||||
...consent,
|
||||
id: "frm_provider",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{ value: "exa", label: "Exa" },
|
||||
{ value: "parallel", label: "Parallel" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const listeners = new Set<(event: OpenCodeEvent) => void>()
|
||||
const replies: FormReplyInput[] = []
|
||||
const abort = new AbortController()
|
||||
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
|
||||
return {
|
||||
form: consent,
|
||||
signal: abort.signal,
|
||||
abort,
|
||||
listeners,
|
||||
replies,
|
||||
events: {
|
||||
listen(listener: (event: OpenCodeEvent) => void) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
},
|
||||
reply: async (input: FormReplyInput) => {
|
||||
replies.push(input)
|
||||
},
|
||||
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
|
||||
cancel: (id: string) =>
|
||||
emit({
|
||||
id: "evt_cancel",
|
||||
created: 0,
|
||||
type: "form.cancelled",
|
||||
data: { id, sessionID: consent.sessionID },
|
||||
}),
|
||||
answer: (id: string, answer: FormAnswer) =>
|
||||
emit({
|
||||
id: "evt_reply",
|
||||
created: 0,
|
||||
type: "form.replied",
|
||||
data: { id, sessionID: consent.sessionID, answer },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe("web search desktop consent", () => {
|
||||
test.each([
|
||||
["random", "allow"],
|
||||
[false, "disable"],
|
||||
] as const)("submits %s without a second form", async (selection, choice) => {
|
||||
const input = fixture()
|
||||
await replyWebSearch({ ...input, selection })
|
||||
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: consent.id, answer: { choice } }])
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("subscribes before the first reply and answers the owning session's provider form", async () => {
|
||||
const input = fixture()
|
||||
await replyWebSearch({
|
||||
...input,
|
||||
selection: "parallel",
|
||||
reply: async (answer) => {
|
||||
input.replies.push(answer)
|
||||
if (answer.answer.choice === "choose") input.create()
|
||||
},
|
||||
})
|
||||
expect(input.replies).toEqual([
|
||||
{ sessionID: consent.sessionID, formID: consent.id, answer: { choice: "choose" } },
|
||||
{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "parallel" } },
|
||||
])
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("ignores questions, other sessions, and repeated consent forms during handoff", async () => {
|
||||
const input = fixture()
|
||||
const pending = replyWebSearch({ ...input, selection: "exa" })
|
||||
input.create({ ...provider, sessionID: "ses_other" })
|
||||
input.create({ ...provider, metadata: { kind: "question" } })
|
||||
input.create(consent)
|
||||
input.create()
|
||||
await pending
|
||||
expect(input.replies).toHaveLength(2)
|
||||
expect(input.replies[1]?.formID).toBe(provider.id)
|
||||
})
|
||||
|
||||
test("leaves a changed provider list for explicit confirmation", async () => {
|
||||
const input = fixture()
|
||||
const pending = replyWebSearch({ ...input, selection: "removed" })
|
||||
input.create()
|
||||
await pending
|
||||
expect(input.replies).toHaveLength(1)
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("supports direct entry into the provider form", async () => {
|
||||
const input = fixture()
|
||||
await replyWebSearch({ ...input, form: provider, selection: "exa" })
|
||||
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "exa" } }])
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("does not misrepresent cancelling the provider form as disabling search", async () => {
|
||||
const input = fixture()
|
||||
await replyWebSearch({ ...input, form: provider, selection: false })
|
||||
expect(input.replies).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["cancel", "other-client", "abort"])("ends a pending handoff on %s", async (action) => {
|
||||
const input = fixture()
|
||||
const pending = replyWebSearch({ ...input, selection: "exa" })
|
||||
if (action === "cancel") input.cancel(consent.id)
|
||||
if (action === "other-client") input.answer(consent.id, { choice: "disable" })
|
||||
if (action === "abort") input.abort.abort()
|
||||
await pending
|
||||
input.create()
|
||||
expect(input.replies).toHaveLength(1)
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("cleans up after a failed consent submission", async () => {
|
||||
const input = fixture()
|
||||
await expect(
|
||||
replyWebSearch({
|
||||
...input,
|
||||
selection: "exa",
|
||||
reply: async () => {
|
||||
throw new Error("offline")
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("offline")
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("propagates provider submission failures for retry", async () => {
|
||||
const input = fixture()
|
||||
await expect(
|
||||
replyWebSearch({
|
||||
...input,
|
||||
selection: "exa",
|
||||
reply: async (answer) => {
|
||||
if (answer.formID === provider.id) throw new Error("offline")
|
||||
input.create()
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("offline")
|
||||
expect(input.listeners.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode-ai/client/promise"
|
||||
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { OpenCodeEventStream } from "@/runtime/server/client"
|
||||
|
||||
export function webSearchProviderField(form: FormInfo) {
|
||||
return form.fields.find(
|
||||
(field): field is FormStringField => field.type === "string" && field.key === "provider" && !!field.options,
|
||||
)
|
||||
}
|
||||
|
||||
export function createWebSearchRequest(input: {
|
||||
owner: () => string | undefined
|
||||
connected: () => boolean
|
||||
request: () => FormInfo | undefined
|
||||
providers: (sessionID: string) => Promise<FormOption[]>
|
||||
reply: (input: FormReplyInput) => Promise<unknown>
|
||||
events: Pick<OpenCodeEventStream, "listen">
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
selected: "random",
|
||||
sending: undefined as { form: FormInfo; abort: AbortController } | undefined,
|
||||
error: false,
|
||||
})
|
||||
const [providers, resource] = createResource(input.request, async (form) => {
|
||||
const field = webSearchProviderField(form)
|
||||
if (field) return field.options ?? []
|
||||
return input.providers(form.sessionID)
|
||||
})
|
||||
const request = createMemo(() => store.sending?.form ?? input.request())
|
||||
const specific = createMemo(() => {
|
||||
const form = request()
|
||||
return !!form && !!webSearchProviderField(form)
|
||||
})
|
||||
const options = createMemo(() => (providers.error ? [] : (providers() ?? [])))
|
||||
const selected = createMemo(() => {
|
||||
if (!specific()) return store.selected
|
||||
return options().some((option) => option.value === store.selected) ? store.selected : options()[0]?.value
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on([input.owner, input.connected], () => {
|
||||
store.sending?.abort.abort()
|
||||
setStore({ sending: undefined, selected: "random", error: false })
|
||||
}),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => input.request()?.id,
|
||||
() => {
|
||||
const form = input.request()
|
||||
if (!form || store.sending || webSearchProviderField(form)) return
|
||||
setStore({ selected: "random", error: false })
|
||||
},
|
||||
),
|
||||
)
|
||||
onCleanup(() => store.sending?.abort.abort())
|
||||
|
||||
const submit = async (selection: string | false) => {
|
||||
const form = input.request()
|
||||
if (!form || store.sending || !input.connected()) return
|
||||
if (selection === false && webSearchProviderField(form)) return
|
||||
const sending = { form, abort: new AbortController() }
|
||||
setStore({ sending, error: false })
|
||||
await replyWebSearch({ ...input, form, selection, signal: sending.abort.signal })
|
||||
.catch(() => {
|
||||
if (!sending.abort.signal.aborted) setStore("error", true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.sending?.abort === sending.abort) setStore("sending", undefined)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
selected,
|
||||
specific,
|
||||
loading: () => providers.loading,
|
||||
loadFailed: () => !!providers.error,
|
||||
failed: () => store.error,
|
||||
sending: () => !!store.sending,
|
||||
connected: input.connected,
|
||||
select: (value: string) => setStore({ selected: value, error: false }),
|
||||
retry: () => void resource.refetch(),
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
export type WebSearchRequestModel = ReturnType<typeof createWebSearchRequest>
|
||||
|
||||
export async function replyWebSearch(input: {
|
||||
form: FormInfo
|
||||
selection: string | false
|
||||
signal: AbortSignal
|
||||
reply: (input: FormReplyInput) => Promise<unknown>
|
||||
events: Pick<OpenCodeEventStream, "listen">
|
||||
}) {
|
||||
if (input.signal.aborted) return
|
||||
if (webSearchProviderField(input.form)) {
|
||||
if (input.selection === false) return
|
||||
return input.reply({
|
||||
sessionID: input.form.sessionID,
|
||||
formID: input.form.id,
|
||||
answer: { provider: input.selection },
|
||||
})
|
||||
}
|
||||
if (input.selection === false || input.selection === "random") {
|
||||
return input.reply({
|
||||
sessionID: input.form.sessionID,
|
||||
formID: input.form.id,
|
||||
answer: { choice: input.selection === false ? "disable" : "allow" },
|
||||
})
|
||||
}
|
||||
|
||||
const next = Promise.withResolvers<FormInfo | undefined>()
|
||||
const stop = input.events.listen((event) => {
|
||||
if (event.type === "form.created") {
|
||||
const form = event.data.form
|
||||
if (
|
||||
form.sessionID !== input.form.sessionID ||
|
||||
form.id === input.form.id ||
|
||||
form.metadata?.kind !== "websearch.provider" ||
|
||||
!webSearchProviderField(form)
|
||||
)
|
||||
return
|
||||
next.resolve(form)
|
||||
}
|
||||
if (event.type === "form.cancelled" && event.data.id === input.form.id) next.resolve(undefined)
|
||||
if (event.type === "form.replied" && event.data.id === input.form.id && event.data.answer.choice !== "choose")
|
||||
next.resolve(undefined)
|
||||
})
|
||||
const cancel = () => next.resolve(undefined)
|
||||
input.signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
return Promise.all([
|
||||
input.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: { choice: "choose" } }),
|
||||
next.promise,
|
||||
])
|
||||
.then(([, form]) => {
|
||||
if (!form || input.signal.aborted) return
|
||||
const field = webSearchProviderField(form)
|
||||
if (!field?.options?.some((option) => option.value === input.selection)) return
|
||||
return input.reply({
|
||||
sessionID: form.sessionID,
|
||||
formID: form.id,
|
||||
answer: { provider: input.selection },
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
stop()
|
||||
input.signal.removeEventListener("abort", cancel)
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
|
||||
import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { createTimelineSearchController } from "./timeline/search-controller"
|
||||
import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
@@ -47,6 +49,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const isDesktop = session.isDesktop
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
sessionID: session.identity.sessionID,
|
||||
scrollRef: timeline.scroller,
|
||||
revealMessage: timeline.actions.revealMessage,
|
||||
pauseAutoScroll: timeline.view.unpin,
|
||||
})
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
@@ -262,6 +270,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={<TimelineSearchBar controller={timelineSearch} />}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,30 +2,54 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createSessionResolution } from "./session-resolution"
|
||||
|
||||
function store() {
|
||||
const syncs = { session: 0, message: 0, pending: 0 }
|
||||
const sessions = {
|
||||
get: () => undefined,
|
||||
sync: () => {
|
||||
syncs.session++
|
||||
return Promise.resolve()
|
||||
},
|
||||
message: {
|
||||
sync: () => {
|
||||
syncs.message++
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
sync: () => {
|
||||
syncs.pending++
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
}
|
||||
return { syncs, sessions }
|
||||
}
|
||||
|
||||
describe("session resolution", () => {
|
||||
test("waits for a route session ID", () => {
|
||||
createRoot((dispose) => {
|
||||
const syncs = { session: 0, message: 0 }
|
||||
const sessions = {
|
||||
get: () => undefined,
|
||||
sync: () => {
|
||||
syncs.session++
|
||||
return Promise.resolve()
|
||||
},
|
||||
message: {
|
||||
sync: () => {
|
||||
syncs.message++
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
}
|
||||
const input = store()
|
||||
const session = createSessionResolution(
|
||||
() => undefined,
|
||||
() => sessions,
|
||||
() => input.sessions,
|
||||
)
|
||||
|
||||
expect(session()).toBeUndefined()
|
||||
expect(syncs).toEqual({ session: 0, message: 0 })
|
||||
expect(input.syncs).toEqual({ session: 0, message: 0, pending: 0 })
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("starts the transcript and queued input reads with metadata", () => {
|
||||
createRoot((dispose) => {
|
||||
const input = store()
|
||||
createSessionResolution(
|
||||
() => "ses_open",
|
||||
() => input.sessions,
|
||||
{ children: true },
|
||||
)
|
||||
expect(input.syncs).toEqual({ session: 1, message: 1, pending: 1 })
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,9 @@ type SessionStore<T> = {
|
||||
message: {
|
||||
sync: (id: string) => Promise<unknown>
|
||||
}
|
||||
pending: {
|
||||
sync: (id: string) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type Resolution<T> = { id: string; store: SessionStore<T> } & (
|
||||
@@ -52,8 +55,11 @@ export function createSessionResolution<T>(
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
// The timeline owns message errors; metadata resolution stays independent.
|
||||
// The timeline owns message errors; metadata resolution stays independent. Queued inputs
|
||||
// ride along so a reconnect refreshes them with the transcript instead of leaving the
|
||||
// pre-disconnect queue on screen.
|
||||
void store.message.sync(id).catch(() => undefined)
|
||||
void store.pending.sync(id).catch(() => undefined)
|
||||
if (cached() && !options?.children && !options?.connected) {
|
||||
setStatus({ id, store, state: "settled" })
|
||||
return
|
||||
|
||||
@@ -138,6 +138,26 @@ export const QuestionRequest = {
|
||||
),
|
||||
}
|
||||
|
||||
export const WebSearchRequest = {
|
||||
render: () => (
|
||||
<SessionPreview
|
||||
title="Search for current documentation"
|
||||
description={description}
|
||||
document={questionPendingDocument}
|
||||
request={{
|
||||
type: "websearch",
|
||||
value: {
|
||||
id: "frm_websearch_preview",
|
||||
sessionID: "ses_websearch_preview",
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [{ key: "choice", type: "string", required: true, custom: false }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const RetryAndInterruption = {
|
||||
render: () => (
|
||||
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ReviewPanelView } from "@/session/review/panel"
|
||||
import { createReviewPanelState } from "@/session/review/panel-state"
|
||||
import { TerminalSurface } from "@/session/terminal/surface"
|
||||
import type { WebSearchRequestModel } from "./requests/websearch"
|
||||
|
||||
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
|
||||
const storyComposerModel = {
|
||||
@@ -82,7 +83,7 @@ export type SessionPreviewProps = {
|
||||
description: string
|
||||
document: SessionDocument
|
||||
draft?: string
|
||||
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
|
||||
request?: { type: "permission"; value: PermissionRequest } | { type: "question" | "websearch"; value: FormInfo }
|
||||
reviewOpened?: boolean
|
||||
child?: { parentID: string }
|
||||
terminal?: { title: string; lines: string[] }
|
||||
@@ -173,10 +174,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
activity: string
|
||||
reviewOpened: boolean
|
||||
request: SessionPreviewProps["request"]
|
||||
searchProvider: string
|
||||
}>({
|
||||
activity: "Ready",
|
||||
reviewOpened: props.reviewOpened ?? false,
|
||||
request: props.request,
|
||||
searchProvider: "random",
|
||||
})
|
||||
const prompt = createPromptController({
|
||||
initial: props.draft ?? "",
|
||||
@@ -189,6 +192,27 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
const region = {
|
||||
state: {
|
||||
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
|
||||
websearch: {
|
||||
request: () => (state.request?.type === "websearch" ? state.request.value : undefined),
|
||||
options: () => [
|
||||
{ value: "exa", label: "Exa" },
|
||||
{ value: "parallel", label: "Parallel" },
|
||||
{ value: "tavily", label: "Tavily" },
|
||||
],
|
||||
selected: () => state.searchProvider,
|
||||
specific: () => false,
|
||||
loading: () => false,
|
||||
loadFailed: () => false,
|
||||
failed: () => false,
|
||||
sending: () => false,
|
||||
connected: () => true,
|
||||
select: (value) => setState("searchProvider", value),
|
||||
retry() {},
|
||||
submit: async (value) => {
|
||||
setState("request", undefined)
|
||||
setState("activity", `Web search selection (local only): ${value}`)
|
||||
},
|
||||
} satisfies WebSearchRequestModel,
|
||||
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
|
||||
permissionResponding: () => false,
|
||||
decide: (response) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pinned: true,
|
||||
},
|
||||
refs: {
|
||||
scroller: undefined as HTMLDivElement | undefined,
|
||||
content: undefined as HTMLDivElement | undefined,
|
||||
dock: undefined as HTMLDivElement | undefined,
|
||||
},
|
||||
@@ -38,7 +39,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
let revealMessage = (_id: string) => {}
|
||||
let revealMessage = (_id: string, _partID?: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
@@ -157,6 +158,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
const setScrollRef = (element: HTMLDivElement | undefined) => {
|
||||
scroller = element
|
||||
setState("refs", "scroller", element)
|
||||
if (!element) return
|
||||
scheduleScrollState(element)
|
||||
fill()
|
||||
@@ -290,6 +292,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
return {
|
||||
actions: {
|
||||
navigateMessage,
|
||||
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
@@ -297,7 +300,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
scroll: state.scroll,
|
||||
scroller: () => scroller,
|
||||
scroller: () => state.refs.scroller,
|
||||
view: {
|
||||
anchor,
|
||||
markUserScroll,
|
||||
@@ -313,7 +316,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setDockRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "dock", element)
|
||||
},
|
||||
setRevealMessage: (reveal: (id: string) => void) => {
|
||||
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
setScrollRef,
|
||||
|
||||
@@ -58,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
icon="outline-arrow-to-corner-top-right"
|
||||
class="max-w-full"
|
||||
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
|
||||
onClick={() => props.onMove?.()}
|
||||
@@ -185,7 +184,7 @@ function WorkspaceMoveAction(props: {
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
@@ -257,7 +256,10 @@ export function SessionSummaryPanel(props: {
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} 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`}
|
||||
>
|
||||
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name={props.local ? "monitor" : "outline-worktree"}
|
||||
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
@@ -354,8 +356,9 @@ type MessageTimelineProps = {
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
search?: JSX.Element
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
@@ -790,6 +793,7 @@ function MessageTimelineView(
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{props.search}
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"] {
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-base);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:hover {
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:focus-within {
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import "@opencode-ai/ui/text-input.css"
|
||||
import { Show } from "solid-js"
|
||||
import type { TimelineSearchController } from "./search-controller"
|
||||
import "./search-bar.css"
|
||||
|
||||
export function TimelineSearchBar(props: { controller: TimelineSearchController }) {
|
||||
const c = props.controller
|
||||
|
||||
return (
|
||||
<Show when={c.visible()}>
|
||||
<div data-component="timeline-search-bar" class="h-7 w-[200px] max-w-[50vw] shrink-0">
|
||||
<div data-component="text-input-v2" data-appearance="base" data-leading-icon class="!h-7 !w-full max-w-full">
|
||||
<div data-slot="text-input-v2-value">
|
||||
<span data-slot="text-input-v2-leading-icon">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
</span>
|
||||
<input
|
||||
ref={c.element.setInput}
|
||||
data-slot="text-input-v2-input"
|
||||
type="search"
|
||||
value={c.query.value()}
|
||||
placeholder={c.query.placeholder()}
|
||||
aria-label={c.query.placeholder()}
|
||||
onInput={(event) => c.query.setValue(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
c.query.close()
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey || event.ctrlKey) return
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(-1)
|
||||
return
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={c.query.value()}>
|
||||
<span
|
||||
data-slot="timeline-search-count"
|
||||
class="shrink-0 self-center text-[11px] text-v2-text-text-muted [font-weight:440] tabular-nums"
|
||||
>
|
||||
{c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()}
|
||||
</span>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="-me-1 flex size-5 shrink-0 self-center items-center justify-center rounded-[2px] border-0 bg-transparent p-0 text-v2-icon-icon-muted outline outline-1 outline-transparent hover:bg-v2-overlay-simple-overlay-hover active:bg-v2-overlay-simple-overlay-pressed focus-visible:outline-v2-border-border-focus"
|
||||
aria-label={c.query.placeholder()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => c.query.close()}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type TimelineSearchMatch = {
|
||||
messageID: string
|
||||
role: "user" | "assistant"
|
||||
revealID: string
|
||||
partID: string
|
||||
occurrence: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const HIGHLIGHT_HIT = "timeline-search-hit"
|
||||
const HIGHLIGHT_ACTIVE = "timeline-search-hit-active"
|
||||
const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]'
|
||||
|
||||
function supportsHighlights() {
|
||||
return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null
|
||||
}
|
||||
|
||||
function clearHighlights() {
|
||||
if (!supportsHighlights()) return
|
||||
CSS.highlights.delete(HIGHLIGHT_HIT)
|
||||
CSS.highlights.delete(HIGHLIGHT_ACTIVE)
|
||||
}
|
||||
|
||||
function collectRanges(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
activePartID: string | undefined,
|
||||
activeOccurrence: number | undefined,
|
||||
) {
|
||||
const hits: Range[] = []
|
||||
const active: Range[] = []
|
||||
const lower = query.toLowerCase()
|
||||
const bodies = root.querySelectorAll<HTMLElement>(TEXT_SELECTORS)
|
||||
for (const body of bodies) {
|
||||
const part = body.closest("[data-timeline-part-id]")
|
||||
const partID = part?.getAttribute("data-timeline-part-id")
|
||||
const isActivePart = activePartID !== undefined && partID === activePartID
|
||||
let occurrenceInPart = 0
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode() as Text | null
|
||||
while (node) {
|
||||
const value = node.nodeValue ?? ""
|
||||
const lowerValue = value.toLowerCase()
|
||||
let from = 0
|
||||
let at = lowerValue.indexOf(lower, from)
|
||||
while (at !== -1) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, at)
|
||||
range.setEnd(node, at + query.length)
|
||||
if (isActivePart && activeOccurrence === occurrenceInPart) active.push(range)
|
||||
else hits.push(range)
|
||||
occurrenceInPart += 1
|
||||
from = at + query.length
|
||||
at = lowerValue.indexOf(lower, from)
|
||||
}
|
||||
node = walker.nextNode() as Text | null
|
||||
}
|
||||
}
|
||||
return { hits, active }
|
||||
}
|
||||
|
||||
function applyHighlights(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
activePartID: string | undefined,
|
||||
activeOccurrence: number | undefined,
|
||||
) {
|
||||
if (!supportsHighlights()) return
|
||||
const { hits, active } = collectRanges(root, query, activePartID, activeOccurrence)
|
||||
CSS.highlights.set(HIGHLIGHT_HIT, new Highlight(...hits))
|
||||
CSS.highlights.set(HIGHLIGHT_ACTIVE, new Highlight(...active))
|
||||
}
|
||||
|
||||
export function createTimelineSearchController(input: {
|
||||
sessionID: () => string | undefined
|
||||
scrollRef: () => HTMLDivElement | undefined
|
||||
revealMessage: (id: string, partID?: string) => void
|
||||
pauseAutoScroll: () => void
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [state, setState] = createStore({ value: "", active: 0, visible: false })
|
||||
const [focusTick, setFocusTick] = createSignal(0)
|
||||
let inputEl: HTMLInputElement | undefined
|
||||
|
||||
const query = createMemo(() => state.value.trim().toLowerCase())
|
||||
|
||||
const matches = createMemo<TimelineSearchMatch[]>(() => {
|
||||
const value = query()
|
||||
if (!value) return []
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) return []
|
||||
const messages = data.session.message.list(sessionID)
|
||||
const result: TimelineSearchMatch[] = []
|
||||
let revealID = ""
|
||||
for (const message of messages) {
|
||||
if (message.type === "user" || message.type === "shell") revealID = message.id
|
||||
if (message.type !== "user" && message.type !== "assistant") continue
|
||||
const visibleParts =
|
||||
message.type === "user"
|
||||
? [{ id: `${message.id}:text:0`, content: { type: "text" as const, text: message.text } }]
|
||||
: Timeline.contentEntries(message)
|
||||
for (const textPart of visibleParts) {
|
||||
if (textPart.content.type !== "text") continue
|
||||
const text = textPart.content.text
|
||||
if (!text) continue
|
||||
const lower = text.toLowerCase()
|
||||
let from = 0
|
||||
let occurrence = 0
|
||||
let at = lower.indexOf(value, from)
|
||||
while (at !== -1) {
|
||||
result.push({
|
||||
messageID: message.id,
|
||||
role: message.type,
|
||||
revealID,
|
||||
partID: textPart.id,
|
||||
occurrence,
|
||||
text,
|
||||
})
|
||||
occurrence += 1
|
||||
from = at + value.length
|
||||
at = lower.indexOf(value, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const activeIndex = createMemo(() => {
|
||||
const list = matches()
|
||||
if (list.length === 0) return 0
|
||||
if (state.active >= list.length) return 0
|
||||
if (state.active < 0) return 0
|
||||
return state.active
|
||||
})
|
||||
|
||||
const activePartID = createMemo(() => matches()[activeIndex()]?.partID)
|
||||
const activeOccurrence = createMemo(() => matches()[activeIndex()]?.occurrence)
|
||||
|
||||
createEffect(() => {
|
||||
const root = input.scrollRef()
|
||||
const q = query()
|
||||
if (!root || !state.visible || !q) {
|
||||
clearHighlights()
|
||||
return
|
||||
}
|
||||
applyHighlights(root, q, activePartID(), activeOccurrence())
|
||||
let frame: number | undefined
|
||||
const scheduleApply = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!state.visible) return
|
||||
applyHighlights(root, query(), activePartID(), activeOccurrence())
|
||||
})
|
||||
}
|
||||
const observer = new MutationObserver(scheduleApply)
|
||||
observer.observe(root, { childList: true, subtree: true, characterData: true })
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
clearHighlights()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(focusTick, () => {
|
||||
if (!state.visible) return
|
||||
requestAnimationFrame(() => {
|
||||
inputEl?.focus()
|
||||
inputEl?.select()
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
command.register("session.search", () => [
|
||||
{
|
||||
id: "session.search",
|
||||
title: language.t("session.search.placeholder"),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => open(),
|
||||
},
|
||||
])
|
||||
const onOpenRequest = () => open()
|
||||
document.addEventListener("opencode:timeline-search-open", onOpenRequest)
|
||||
onCleanup(() => document.removeEventListener("opencode:timeline-search-open", onOpenRequest))
|
||||
|
||||
function open() {
|
||||
setState("visible", true)
|
||||
setFocusTick((t) => t + 1)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setState({ value: "", active: 0, visible: false })
|
||||
inputEl?.blur()
|
||||
}
|
||||
|
||||
function setValue(value: string) {
|
||||
setState("value", value)
|
||||
const list = matches()
|
||||
const match = list[0]
|
||||
if (!value.trim() || !match) {
|
||||
setState("active", 0)
|
||||
return
|
||||
}
|
||||
setState("active", 0)
|
||||
input.pauseAutoScroll()
|
||||
input.revealMessage(match.revealID, match.partID)
|
||||
scrollToMatch(match)
|
||||
}
|
||||
|
||||
function scrollToMatch(match: TimelineSearchMatch) {
|
||||
let attempts = 0
|
||||
const seek = () => {
|
||||
if (!state.visible) return
|
||||
const root = input.scrollRef()
|
||||
if (!root) return
|
||||
const { active } = collectRanges(root, query(), match.partID, match.occurrence)
|
||||
if (active.length === 0) {
|
||||
if (attempts++ < 12) requestAnimationFrame(seek)
|
||||
return
|
||||
}
|
||||
const rect = active[0].getBoundingClientRect()
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = rect.top - rootRect.top + root.scrollTop - inset - (rootRect.height - rect.height) / 2
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: "auto" })
|
||||
}
|
||||
requestAnimationFrame(seek)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const list = matches()
|
||||
if (list.length === 0) return
|
||||
const next = (activeIndex() + delta + list.length) % list.length
|
||||
setState("active", next)
|
||||
const match = list[next]
|
||||
if (!match) return
|
||||
input.pauseAutoScroll()
|
||||
input.revealMessage(match.revealID, match.partID)
|
||||
scrollToMatch(match)
|
||||
}
|
||||
|
||||
return {
|
||||
visible: () => state.visible,
|
||||
query: {
|
||||
value: () => state.value,
|
||||
placeholder: () => language.t("session.search.placeholder"),
|
||||
noResults: () => language.t("session.search.noResults"),
|
||||
open,
|
||||
close,
|
||||
setValue,
|
||||
},
|
||||
result: {
|
||||
activeIndex,
|
||||
count: () => matches().length,
|
||||
move,
|
||||
},
|
||||
element: {
|
||||
setInput: (element: HTMLInputElement) => (inputEl = element),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineSearchController = ReturnType<typeof createTimelineSearchController>
|
||||
@@ -7,7 +7,6 @@ import type { Project } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
@@ -28,7 +27,6 @@ export function SessionWorkspaceMenu(props: {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
||||
@@ -99,13 +97,13 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<Icon name="plus" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<Menu.SubTrigger>
|
||||
<Icon name="workspace-isolated" />
|
||||
<Icon name="outline-worktree" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
@@ -113,7 +111,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -123,10 +121,6 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu.Sub>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<Menu.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -69,7 +69,7 @@ type Input = {
|
||||
row: TimelineRow.TimelineRow,
|
||||
disclosure: Readonly<Record<string, boolean | undefined>>,
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
}
|
||||
|
||||
@@ -271,8 +271,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
input.setRevealMessage?.((id) => {
|
||||
const index = input.projection.messageRowIndex().get(id)
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
const partIndex = partID
|
||||
? rows().findIndex(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
)
|
||||
: -1
|
||||
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -61,6 +61,22 @@ export const SettingsExperimental: Component = () => {
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={settings.general.setShowProjectIcon}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.showProjectIcon.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -348,20 +348,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
|
||||
@@ -1029,25 +1029,92 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
opacity: 1;
|
||||
transition:
|
||||
grid-template-rows 150ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion[data-removing="true"] {
|
||||
grid-template-rows: minmax(0, 0fr);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
padding-bottom 150ms ease-out,
|
||||
margin-bottom 150ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-row:not(:last-child) {
|
||||
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
|
||||
> .settings-workspaces-row {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 0fr);
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
grid-template-rows 150ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion[data-visible="true"] {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion > .settings-workspaces-empty {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-workspaces-row-motion {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.settings-workspaces-row,
|
||||
.settings-workspaces-empty-motion {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1088,7 +1155,7 @@
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1098,6 +1165,11 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-workspaces-path-name {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.settings-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
@@ -1105,6 +1177,10 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-meta-project {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-active,
|
||||
.settings-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
@@ -1114,11 +1190,16 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-active {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.settings-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1129,7 +1210,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
@@ -1149,22 +1231,28 @@
|
||||
|
||||
.settings-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
@@ -35,7 +36,7 @@ const sections = [
|
||||
[
|
||||
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
|
||||
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
|
||||
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
|
||||
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
|
||||
],
|
||||
[
|
||||
{ value: "providers", icon: "providers", label: "settings.providers.title" },
|
||||
@@ -54,6 +55,7 @@ export const SettingsScreen: Component = () => {
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
@@ -146,7 +148,14 @@ export const SettingsScreen: Component = () => {
|
||||
</Show>
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Menu.RadioItem value={section.value} closeOnSelect>
|
||||
<Menu.RadioItem
|
||||
value={section.value}
|
||||
closeOnSelect
|
||||
onSelect={() => {
|
||||
if (section.value === "workspaces")
|
||||
setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Menu.RadioItem>
|
||||
@@ -172,7 +181,12 @@ export const SettingsScreen: Component = () => {
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Tabs.Trigger value={section.value}>
|
||||
<Tabs.Trigger
|
||||
value={section.value}
|
||||
onClick={() => {
|
||||
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Tabs.Trigger>
|
||||
@@ -208,7 +222,10 @@ export const SettingsScreen: Component = () => {
|
||||
</Tabs.Content>
|
||||
<SettingsServerScope directory={directory()}>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces activeDirectory={directory()} />
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={directory()}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={directory()} onBack={showProviders} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -46,7 +48,9 @@ type Workspace = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
|
||||
props,
|
||||
) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -56,6 +60,12 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
deleting: [] as string[],
|
||||
removing: [] as string[],
|
||||
})
|
||||
createEffect(() => {
|
||||
props.resetProjectFilter()
|
||||
setStore("project", "all")
|
||||
})
|
||||
|
||||
const projectQuery = useQuery(() => ({
|
||||
@@ -111,6 +121,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
] as const,
|
||||
queryFn: () => loadSessions(workspaceDirectories()),
|
||||
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
|
||||
placeholderData: (previous) => previous,
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const sessionsByWorkspace = createMemo(() => {
|
||||
@@ -123,14 +134,29 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
)
|
||||
})
|
||||
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
|
||||
const workspacesWithoutSessions = createMemo(() => {
|
||||
if (sessionQuery.isPending || sessionQuery.isError) return []
|
||||
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
|
||||
})
|
||||
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, {
|
||||
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
|
||||
const project = projectName(workspace.project)
|
||||
const label = language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project: projectName(workspace.project),
|
||||
project,
|
||||
})
|
||||
const start = label.lastIndexOf(project)
|
||||
if (start < 0) return label
|
||||
return (
|
||||
<>
|
||||
{label.slice(0, start)}
|
||||
<span class="settings-workspaces-meta-project">{project}</span>
|
||||
{label.slice(start + project.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
@@ -158,54 +184,64 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
}
|
||||
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
|
||||
const messages = [
|
||||
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
|
||||
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
|
||||
result.dirty ? language.t("workspace.status.dirty") : undefined,
|
||||
].filter((message): message is string => message !== undefined)
|
||||
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
|
||||
return messages
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: inspectionMessages(result)[0],
|
||||
description: result.active
|
||||
? language.t("settings.workspaces.delete.blocked.active")
|
||||
: inspectionMessages(result)[0],
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
const key = String(pathKey(workspace.directory))
|
||||
setStore("deleting", (items) => [...items, key])
|
||||
try {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
.then(() => true)
|
||||
.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
|
||||
setStore("removing", (items) => [...items, key])
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
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,
|
||||
})
|
||||
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, platform, context.sdk.scope)
|
||||
await projectQuery.refetch()
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await projectQuery.refetch()
|
||||
} finally {
|
||||
setStore("deleting", (items) => items.filter((item) => item !== key))
|
||||
setStore("removing", (items) => items.filter((item) => item !== key))
|
||||
}
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
@@ -253,13 +289,30 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
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}
|
||||
<DialogDeleteWorkspaces
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
|
||||
warning={language.t("settings.workspaces.deleteAll.warning")}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const confirmDeleteWithoutSessions = () => {
|
||||
if (store.transaction || workspacesWithoutSessions().length === 0) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...workspacesWithoutSessions()]
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspaces
|
||||
title={language.t("settings.workspaces.deleteWithoutSessions")}
|
||||
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
|
||||
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
@@ -271,44 +324,45 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
<>
|
||||
<div class="settings-tab-header settings-workspaces-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-workspaces">
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-workspaces-count">
|
||||
<Show when={!projectQuery.isPending && !projectQuery.isError}>
|
||||
<Show when={filtered().length > 0}>
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-workspaces-count">
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</Show>
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium 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">
|
||||
<span class="min-w-0 truncate">
|
||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={projectOptions()}>
|
||||
{(option) => (
|
||||
<Menu.Item onSelect={() => setStore("project", option.id)}>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<Show when={selectedProject() === option.id}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
|
||||
<span class="min-w-0 truncate">
|
||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={projectOptions()}>
|
||||
{(option) => (
|
||||
<Menu.Item onSelect={() => setStore("project", option.id)}>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<Show when={selectedProject() === option.id}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Menu placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
@@ -321,78 +375,83 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Show when={workspacesWithoutSessions().length > 0}>
|
||||
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
|
||||
{language.t("settings.workspaces.deleteWithoutSessions")}
|
||||
</Menu.Item>
|
||||
<Menu.Separator />
|
||||
</Show>
|
||||
<Menu.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="settings-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
|
||||
<SettingsList>
|
||||
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
|
||||
<div class="settings-workspaces-empty">
|
||||
{language.t(
|
||||
projectQuery.isPending
|
||||
? "common.loading"
|
||||
: projectQuery.isError
|
||||
? "common.requestFailed"
|
||||
: "settings.workspaces.empty",
|
||||
)}
|
||||
<Show
|
||||
when={!projectQuery.isPending && !projectQuery.isError}
|
||||
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
|
||||
>
|
||||
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
|
||||
<span>{language.t("settings.workspaces.empty.description")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsList>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
</div>
|
||||
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace())
|
||||
const key = () => String(pathKey(workspace().directory))
|
||||
const deleting = () => store.deleting.includes(key())
|
||||
return (
|
||||
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
|
||||
<div class="settings-workspaces-row">
|
||||
<div class="settings-workspaces-row-header">
|
||||
<div class="settings-workspaces-copy">
|
||||
<div class="settings-workspaces-main">
|
||||
<Tooltip
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
dir="ltr"
|
||||
aria-label={workspace.directory}
|
||||
class="settings-workspaces-path"
|
||||
>
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<WorkspacePath directory={workspace().directory} />
|
||||
</div>
|
||||
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
|
||||
</div>
|
||||
<div class="settings-workspaces-row-actions">
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
|
||||
<span tabIndex={0} class="settings-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Show
|
||||
when={deleting()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={lastActive(workspace())}>
|
||||
{(value) => (
|
||||
<Tooltip
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace().directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace())}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span class="settings-workspaces-active">{language.t("workspace.lifecycle.deleting")}</span>
|
||||
</Show>
|
||||
<IconButton
|
||||
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}>
|
||||
@@ -401,7 +460,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
{(session) => (
|
||||
<div class="settings-workspaces-session">
|
||||
<span>{sessionLabel(session)}</span>
|
||||
<Show when={sessionTime(session)}>
|
||||
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
|
||||
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -410,18 +469,48 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Key>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
function WorkspacePath(props: { directory: string }) {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const name = () => getFilename(props.directory)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={props.directory}
|
||||
placement="top-start"
|
||||
disabled={!truncated()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
|
||||
tabIndex={truncated() ? 0 : undefined}
|
||||
dir="ltr"
|
||||
aria-label={props.directory}
|
||||
class="settings-workspaces-path"
|
||||
>
|
||||
<span>{props.directory.slice(0, -name().length)}</span>
|
||||
<span class="settings-workspaces-path-name">{name()}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspaces(props: {
|
||||
title: string
|
||||
confirmation: string
|
||||
warning: string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
@@ -434,13 +523,12 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
title={props.title}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>{props.confirmation}</div>
|
||||
<div>{props.warning}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
@@ -449,7 +537,7 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
{language.t("settings.workspaces.delete.button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
@@ -472,7 +560,7 @@ function DialogDeleteWorkspace(props: {
|
||||
staleTime: 0,
|
||||
}))
|
||||
const descriptions = () => {
|
||||
if (status.isPending) return [language.t("workspace.status.checking")]
|
||||
if (status.isPending) return []
|
||||
if (status.isError) return [language.t("workspace.status.error")]
|
||||
if (!status.data) return []
|
||||
return props.inspectionMessages(status.data.result)
|
||||
@@ -487,18 +575,20 @@ function DialogDeleteWorkspace(props: {
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.title")}
|
||||
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
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")}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-11-regular text-v2-text-text-faint">
|
||||
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
|
||||
</span>
|
||||
<code class="block w-fit 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>
|
||||
</div>
|
||||
<div>{language.t("settings.workspaces.delete.warning")}</div>
|
||||
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
@@ -506,12 +596,7 @@ function DialogDeleteWorkspace(props: {
|
||||
<Button type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={status.isPending || status.isError || status.data?.result.active}
|
||||
onClick={remove}
|
||||
>
|
||||
<Button type="button" variant="danger" disabled={status.isPending || status.isError} onClick={remove}>
|
||||
{language.t("workspace.delete.button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
@@ -29,12 +29,12 @@ export function useSessionTabAvatarState(
|
||||
if (!ctx) return false
|
||||
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
const hasForms = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
if (!data) return false
|
||||
return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
|
||||
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
|
||||
})
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasForms())
|
||||
const unread = createMemo(
|
||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="-end-2"
|
||||
direction="horizontal"
|
||||
size={state.tabsWidth}
|
||||
min={130}
|
||||
min={140}
|
||||
max={520}
|
||||
onResize={(width) => setState("tabsWidth", width)}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user