mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 09:26:26 +00:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e36f7120 | ||
|
|
42bccc301e | ||
|
|
243e81041e | ||
|
|
5d971e5272 | ||
|
|
7ef9cb68d1 | ||
|
|
733f138dae | ||
|
|
0ff9ccb227 | ||
|
|
5ff8a3f5fa | ||
|
|
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 |
@@ -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:"
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ export const route = Route.make({
|
||||
|
||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
|
||||
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
|
||||
|
||||
@@ -115,6 +115,7 @@ Keep provider facades small and explicit:
|
||||
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
|
||||
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
|
||||
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
||||
- Give every named provider its own file and top-level export. Keep its endpoint, auth defaults, and route setup in that file. Compose shared protocols directly; do not nest named provider presets under generic compatible facades or keep their endpoints in a shared provider profile registry.
|
||||
|
||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||
|
||||
|
||||
+21
-2
@@ -461,7 +461,18 @@ const gateway = CloudflareAIGateway.configure({
|
||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
|
||||
|
||||
Each named provider owns its module, endpoint, authentication, and route setup. Providers with the same wire format compose the shared protocol directly:
|
||||
|
||||
```ts
|
||||
import { DeepSeek, Fireworks } from "@opencode-ai/ai/providers"
|
||||
|
||||
const deepseek = DeepSeek.configure({ apiKey }).model("deepseek-chat")
|
||||
const fireworks = Fireworks.configure({ apiKey }).model("accounts/fireworks/models/my-model")
|
||||
```
|
||||
|
||||
The former `OpenAICompatible.baseten`, `.cerebras`, `.deepinfra`, `.deepseek`, `.fireworks`, `.groq`, and `.togetherai` presets are replaced by the top-level `Baseten`, `Cerebras`, `DeepInfra`, `DeepSeek`, `Fireworks`, `Groq`, and `TogetherAI` exports. Use `CloudflareAIGateway` and `CloudflareWorkersAI` directly; each has its own module. `OpenAICompatible` configures generic endpoints with an explicit `baseURL`.
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
@@ -519,7 +530,15 @@ model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
|
||||
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
|
||||
Every named LLM provider listed above also exports `model(modelID, settings)` from its own package entrypoint. The extracted providers are available at:
|
||||
|
||||
- `@opencode-ai/ai/providers/baseten`
|
||||
- `@opencode-ai/ai/providers/deepseek`
|
||||
- `@opencode-ai/ai/providers/fireworks`
|
||||
- `@opencode-ai/ai/providers/cloudflare-ai-gateway`
|
||||
- `@opencode-ai/ai/providers/cloudflare-workers-ai`
|
||||
|
||||
Core resolves known compatible catalog providers through their dedicated entrypoints, including the `fireworks-ai` catalog ID through `providers/fireworks`.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -7,7 +7,8 @@ import { AwsV4Signer } from "aws4fetch"
|
||||
import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import * as ProviderShared from "../src/protocols/shared"
|
||||
import * as Cloudflare from "../src/providers/cloudflare"
|
||||
import { CloudflareAIGateway } from "../src/providers/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
|
||||
|
||||
type Provider = {
|
||||
readonly id: string
|
||||
@@ -120,11 +121,11 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${Cloudflare.aiGatewayBaseURL({
|
||||
url: `${CloudflareAIGateway.baseURL({
|
||||
accountId: env.CLOUDFLARE_ACCOUNT_ID,
|
||||
gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined,
|
||||
})}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)),
|
||||
token: Redacted.make(envValue(env, CloudflareAIGateway.authEnvVars)),
|
||||
tokenHeader: "cf-aig-authorization",
|
||||
model: "workers-ai/@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
@@ -140,8 +141,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)),
|
||||
url: `${CloudflareWorkersAI.baseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, CloudflareWorkersAI.authEnvVars)),
|
||||
model: "@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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,60 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("baseten")
|
||||
const baseURL = "https://inference.baseten.co/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "baseten-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "baseten",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "BASETEN_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Baseten from "./baseten.js"
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cerebras")
|
||||
const baseURL = "https://api.cerebras.ai/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -20,19 +21,22 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
export const route = Route.make({
|
||||
id: "cerebras-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.cerebras.baseURL },
|
||||
providerMetadataKey: "cerebras",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Config, Redacted } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
export const authEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
|
||||
|
||||
type GatewayURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}> & {
|
||||
readonly gatewayId?: string
|
||||
}
|
||||
|
||||
export type LanguageModelOptions = GatewayURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||
readonly gatewayApiKey?: string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
GatewayURL & {
|
||||
readonly apiKey?: string
|
||||
readonly gatewayApiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
const auth = (input: LanguageModelOptions) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
|
||||
.orElse(Auth.config(authEnvVars[0]))
|
||||
.orElse(Auth.config(authEnvVars[1]))
|
||||
.pipe(Auth.bearerHeader("cf-aig-authorization"))
|
||||
if (!("apiKey" in input) || input.apiKey === undefined) return gateway
|
||||
if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey)
|
||||
return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey))
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "cloudflare-ai-gateway",
|
||||
provider: id,
|
||||
providerMetadataKey: "cloudflare-ai-gateway",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions) => {
|
||||
const {
|
||||
accountId: _accountId,
|
||||
gatewayId: _gatewayId,
|
||||
apiKey: _apiKey,
|
||||
gatewayApiKey: _gatewayApiKey,
|
||||
baseURL: _baseURL,
|
||||
auth: _auth,
|
||||
...defaults
|
||||
} = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL(input) },
|
||||
auth: auth(input),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
gatewayApiKey: settings.gatewayApiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
|
||||
|
||||
type WorkersAIURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}>
|
||||
|
||||
export type LanguageModelOptions = WorkersAIURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
WorkersAIURL & {
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "cloudflare-workers-ai",
|
||||
provider: id,
|
||||
providerMetadataKey: "cloudflare-workers-ai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions) => {
|
||||
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL(input) },
|
||||
auth: AuthOptions.bearer(input, authEnvVars),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Config, Redacted } from "effect"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
|
||||
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
|
||||
export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
|
||||
export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
|
||||
|
||||
type CloudflareSecret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||
|
||||
type GatewayURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}> & {
|
||||
readonly gatewayId?: string
|
||||
}
|
||||
|
||||
export type AIGatewayOptions = GatewayURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||
readonly gatewayApiKey?: CloudflareSecret
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
type WorkersAIURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}>
|
||||
|
||||
export type WorkersAIOptions = WorkersAIURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const aiGatewayBaseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
const aiGatewayAuth = (input: AIGatewayOptions) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
|
||||
.orElse(Auth.config("CLOUDFLARE_API_TOKEN"))
|
||||
.orElse(Auth.config("CF_AIG_TOKEN"))
|
||||
.pipe(Auth.bearerHeader("cf-aig-authorization"))
|
||||
if (!("apiKey" in input) || input.apiKey === undefined) return gateway
|
||||
if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey)
|
||||
return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey))
|
||||
}
|
||||
|
||||
export const workersAIBaseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
const workersAIAuth = (input: WorkersAIOptions) => {
|
||||
return AuthOptions.bearer(input, workersAIAuthEnvVars)
|
||||
}
|
||||
|
||||
export const aiGatewayRoute = OpenAICompatibleChat.route.with({
|
||||
id: "cloudflare-ai-gateway",
|
||||
provider: aiGatewayID,
|
||||
})
|
||||
|
||||
export const workersAIRoute = OpenAICompatibleChat.route.with({
|
||||
id: "cloudflare-workers-ai",
|
||||
provider: workersAIID,
|
||||
})
|
||||
|
||||
export const routes = [aiGatewayRoute, workersAIRoute]
|
||||
|
||||
const aiGatewayDefaults = (options: AIGatewayOptions) => {
|
||||
const {
|
||||
accountId: _accountId,
|
||||
gatewayId: _gatewayId,
|
||||
apiKey: _apiKey,
|
||||
gatewayApiKey: _gatewayApiKey,
|
||||
baseURL: _baseURL,
|
||||
auth: _auth,
|
||||
...rest
|
||||
} = options
|
||||
return rest
|
||||
}
|
||||
|
||||
const workersAIDefaults = (options: WorkersAIOptions) => {
|
||||
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...rest } = options
|
||||
return rest
|
||||
}
|
||||
|
||||
const configureAIGateway = (options: AIGatewayOptions) => {
|
||||
const route = aiGatewayRoute.with({
|
||||
...aiGatewayDefaults(options),
|
||||
endpoint: { baseURL: aiGatewayBaseURL(options) },
|
||||
auth: aiGatewayAuth(options),
|
||||
})
|
||||
return {
|
||||
id: aiGatewayID,
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure: configureAIGateway,
|
||||
}
|
||||
}
|
||||
|
||||
const configureWorkersAI = (options: WorkersAIOptions) => {
|
||||
const route = workersAIRoute.with({
|
||||
...workersAIDefaults(options),
|
||||
endpoint: { baseURL: workersAIBaseURL(options) },
|
||||
auth: workersAIAuth(options),
|
||||
})
|
||||
return {
|
||||
id: workersAIID,
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure: configureWorkersAI,
|
||||
}
|
||||
}
|
||||
|
||||
export const CloudflareAIGateway = {
|
||||
id: aiGatewayID,
|
||||
configure: configureAIGateway,
|
||||
}
|
||||
|
||||
export const CloudflareWorkersAI = {
|
||||
id: workersAIID,
|
||||
configure: configureWorkersAI,
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepinfra")
|
||||
const baseURL = "https://api.deepinfra.com/v1/openai"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -20,21 +21,24 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
export const route = Route.make({
|
||||
id: "deepinfra-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.deepinfra.baseURL },
|
||||
providerMetadataKey: "deepinfra",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const root = baseURL?.replace(/\/+$/, "")
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const root = endpoint?.replace(/\/+$/, "")
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: {
|
||||
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
baseURL: root === undefined ? baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
},
|
||||
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepseek")
|
||||
const baseURL = "https://api.deepseek.com/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "deepseek-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "deepseek",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "DEEPSEEK_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("fireworks")
|
||||
const baseURL = "https://api.fireworks.ai/inference/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "fireworks-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "fireworks",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "FIREWORKS_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
@@ -24,10 +25,13 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
const route = Route.make({
|
||||
id: "google-vertex-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
@@ -24,11 +25,14 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
const route = Route.make({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerOptions: { store: false },
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("groq")
|
||||
const baseURL = "https://api.groq.com/openai/v1"
|
||||
|
||||
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
|
||||
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
|
||||
@@ -73,15 +73,15 @@ export const route = Route.make({
|
||||
provider: id,
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -3,10 +3,13 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
|
||||
export * as AmazonBedrock from "./amazon-bedrock.js"
|
||||
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
|
||||
export * as Azure from "./azure.js"
|
||||
export * as Baseten from "./baseten.js"
|
||||
export * as Cerebras from "./cerebras.js"
|
||||
export * as Cloudflare from "./cloudflare.js"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
export * as Google from "./google.js"
|
||||
export * as GoogleVertex from "./google-vertex.js"
|
||||
export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface OpenAICompatibleProfile {
|
||||
readonly provider: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export const profiles = {
|
||||
baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
|
||||
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
|
||||
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
|
||||
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
|
||||
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
|
||||
groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" },
|
||||
openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" },
|
||||
togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
|
||||
xai: { provider: "xai", baseURL: "https://api.x.ai/v1" },
|
||||
} as const satisfies Record<string, OpenAICompatibleProfile>
|
||||
|
||||
export const byProvider: Record<string, OpenAICompatibleProfile> = Object.fromEntries(
|
||||
Object.values(profiles).map((profile) => [profile.provider, profile]),
|
||||
)
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
@@ -22,12 +21,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
export const configure = (input: GenericModelOptions) => {
|
||||
@@ -47,22 +40,6 @@ export const configure = (input: GenericModelOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
const define = (profile: OpenAICompatibleProfile) => {
|
||||
const configureProfile = (input: FamilyModelOptions = {}) => {
|
||||
const facade = configure({
|
||||
...input,
|
||||
baseURL: input.baseURL ?? profile.baseURL,
|
||||
provider: profile.provider,
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(profile.provider),
|
||||
model: facade.model,
|
||||
configure: configureProfile,
|
||||
}
|
||||
}
|
||||
return configureProfile()
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
@@ -78,10 +55,4 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
export const cerebras = define(profiles.cerebras)
|
||||
export const deepinfra = define(profiles.deepinfra)
|
||||
export const deepseek = define(profiles.deepseek)
|
||||
export const fireworks = define(profiles.fireworks)
|
||||
export const groq = define(profiles.groq)
|
||||
export const togetherai = define(profiles.togetherai)
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
|
||||
@@ -5,13 +5,12 @@ import { Protocol } from "../route/protocol.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord } from "../protocols/shared.js"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
export const id = ProviderID.make("openrouter")
|
||||
const baseURL = "https://openrouter.ai/api/v1"
|
||||
const ADAPTER = "openrouter"
|
||||
|
||||
type OpenRouterString<Known extends string> = Known | (string & {})
|
||||
@@ -162,20 +161,20 @@ const bodyOptions = (input: unknown) => {
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: profile.provider,
|
||||
provider: id,
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: LanguageModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? profile.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("togetherai")
|
||||
const baseURL = "https://api.together.xyz/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -20,19 +21,22 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
export const route = Route.make({
|
||||
id: "togetherai-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.togetherai.baseURL },
|
||||
providerMetadataKey: "togetherai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
@@ -12,6 +10,7 @@ import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
const baseURL = "https://api.x.ai/v1"
|
||||
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
|
||||
|
||||
@@ -37,7 +36,7 @@ const responsesRoute = Route.make({
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
endpoint: Endpoint.path("/responses", { baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
@@ -51,8 +50,8 @@ const chatRoute = Route.make({
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAICompatibleChat.route.transport,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
headers: ({ request }): Record<string, string> =>
|
||||
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
|
||||
})
|
||||
@@ -62,19 +61,19 @@ export const routes = [responsesRoute, chatRoute]
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||
|
||||
const configuredResponsesRoute = (input: LanguageModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
|
||||
return responsesRoute.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
}
|
||||
|
||||
const configuredChatRoute = (input: LanguageModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
|
||||
return chatRoute.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
}
|
||||
@@ -88,7 +87,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@ import * as AmazonBedrock from "../src/providers/amazon-bedrock.js"
|
||||
import * as Anthropic from "../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../src/providers/anthropic-compatible.js"
|
||||
import * as Azure from "../src/providers/azure.js"
|
||||
import * as Cloudflare from "../src/providers/cloudflare.js"
|
||||
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
|
||||
import { DeepSeek } from "../src/providers/deepseek.js"
|
||||
import * as Google from "../src/providers/google.js"
|
||||
import * as GoogleVertex from "../src/providers/google-vertex.js"
|
||||
import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
|
||||
@@ -263,10 +264,10 @@ XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {})
|
||||
// @ts-expect-error xAI Chat selectors only accept model ids.
|
||||
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
|
||||
|
||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
|
||||
DeepSeek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
|
||||
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
|
||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
|
||||
DeepSeek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
|
||||
|
||||
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
|
||||
CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
|
||||
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
|
||||
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
|
||||
CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
|
||||
|
||||
@@ -3,8 +3,11 @@ import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@o
|
||||
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
|
||||
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||
import {
|
||||
Baseten,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepSeek,
|
||||
Fireworks,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
@@ -48,7 +51,13 @@ describe("public exports", () => {
|
||||
expect(OpenAI.model).toBeFunction()
|
||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||
for (const provider of [Baseten, DeepSeek, Fireworks]) {
|
||||
expect(provider.configure).toBeFunction()
|
||||
expect(provider.model).toBeFunction()
|
||||
}
|
||||
for (const name of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks", "groq", "togetherai"]) {
|
||||
expect(OpenAICompatible).not.toHaveProperty(name)
|
||||
}
|
||||
expect(
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://responses.test/v1" }).model("fixture").route.id,
|
||||
).toBe("openai-compatible-responses")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { OpenAICompatible } from "../../src/providers.js"
|
||||
|
||||
const model = OpenAICompatible.deepseek.model("deepseek-chat")
|
||||
const model = OpenAICompatible.configure({ baseURL: "https://compatible.example/v1" }).model("test-model")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { store: false } })
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
import("@opencode-ai/ai/providers/deepinfra"),
|
||||
import("@opencode-ai/ai/providers/groq"),
|
||||
import("@opencode-ai/ai/providers/baseten"),
|
||||
import("@opencode-ai/ai/providers/deepseek"),
|
||||
import("@opencode-ai/ai/providers/fireworks"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
@@ -60,6 +65,27 @@ describe("provider package entrypoints", () => {
|
||||
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
|
||||
test("maps Cloudflare package settings onto provider-owned models", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
|
||||
])
|
||||
for (const provider of modules) {
|
||||
const selected = provider.model("provider-model", {
|
||||
accountId: "account",
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { custom: true },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ custom: true })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
})
|
||||
|
||||
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
||||
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
||||
const XAI = await import("@opencode-ai/ai/providers/xai")
|
||||
|
||||
@@ -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 }> = []
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import { CloudflareAIGateway } from "../../src/providers/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAI } from "../../src/providers/cloudflare-workers-ai.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import {
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
DeepSeek,
|
||||
TogetherAI,
|
||||
} from "../../src/providers/index.js"
|
||||
import * as Google from "../../src/providers/google.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
|
||||
@@ -45,16 +51,18 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met
|
||||
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
|
||||
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
||||
const deepseek = OpenAICompatible.deepseek
|
||||
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
|
||||
.model("deepseek-chat")
|
||||
const deepseek = DeepSeek.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }).model("deepseek-chat")
|
||||
const together = TogetherAI.configure({
|
||||
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
|
||||
const groq = OpenAICompatible.groq
|
||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
||||
.model("llama-3.3-70b-versatile")
|
||||
// These older cassettes exercise generic Chat compatibility. Native Groq request
|
||||
// shaping and reasoning are covered by groq.recorded.test.ts.
|
||||
const groq = OpenAICompatible.configure({
|
||||
provider: "groq",
|
||||
baseURL: "https://api.groq.com/openai/v1",
|
||||
apiKey: process.env.GROQ_API_KEY ?? "fixture",
|
||||
}).model("llama-3.3-70b-versatile")
|
||||
const deepInfra = DeepInfra.configure({ apiKey: process.env.DEEPINFRA_API_KEY ?? "fixture" }).model(
|
||||
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
Anthropic,
|
||||
AnthropicCompatible,
|
||||
Azure,
|
||||
Baseten,
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
DeepSeek,
|
||||
Fireworks,
|
||||
Google,
|
||||
GoogleVertex,
|
||||
GoogleVertexChat,
|
||||
@@ -57,6 +60,9 @@ describe("native OpenAI-compatible providers", () => {
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[Baseten.configure({ apiKey: "test" }).model("model"), "baseten"],
|
||||
[DeepSeek.configure({ apiKey: "test" }).model("model"), "deepseek"],
|
||||
[Fireworks.configure({ apiKey: "test" }).model("model"), "fireworks"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
@@ -87,6 +93,36 @@ describe("native OpenAI-compatible providers", () => {
|
||||
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
|
||||
})
|
||||
|
||||
it.effect("preserves extracted providers' Chat requests and identity through custom endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const provider of [Baseten, DeepSeek, Fireworks]) {
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
}
|
||||
const selected = provider.configure(settings).model("test-model")
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.id).toBe(`${provider.id}-chat`)
|
||||
expect(selected.route.protocol).toBe("openai-chat")
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
const input = {
|
||||
prompt: "Use a tool.",
|
||||
generation: { maxTokens: 48 },
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}
|
||||
const native = yield* compileRequest(LLM.request({ ...input, model: selected }))
|
||||
const generic = yield* compileRequest(
|
||||
LLM.request({
|
||||
...input,
|
||||
model: OpenAICompatible.configure({ ...settings, provider: provider.id }).model("test-model"),
|
||||
}),
|
||||
)
|
||||
expect(native.body).toEqual(generic.body)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
test("preserves native DeepInfra provider and route identity", () => {
|
||||
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
|
||||
expect(deepinfra).toMatchObject({
|
||||
@@ -164,7 +200,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
})
|
||||
|
||||
test("maps package settings onto native executable models", () => {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
for (const native of [Baseten, Cerebras, DeepSeek, Fireworks, TogetherAI]) {
|
||||
const selected = native.model("provider-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
@@ -183,6 +219,24 @@ describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const scenarios = [
|
||||
{
|
||||
model: Baseten.configure().model("model"),
|
||||
env: { BASETEN_API_KEY: "baseten-secret" },
|
||||
token: "baseten-secret",
|
||||
url: "https://inference.baseten.co/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: DeepSeek.configure().model("deepseek-chat"),
|
||||
env: { DEEPSEEK_API_KEY: "deepseek-secret" },
|
||||
token: "deepseek-secret",
|
||||
url: "https://api.deepseek.com/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: Fireworks.configure().model("model"),
|
||||
env: { FIREWORKS_API_KEY: "fireworks-secret" },
|
||||
token: "fireworks-secret",
|
||||
url: "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: TogetherAI.configure().model("llama"),
|
||||
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
|
||||
|
||||
@@ -4,7 +4,6 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
@@ -41,15 +40,6 @@ const usageChunk = (usage: object) => ({
|
||||
usage,
|
||||
})
|
||||
|
||||
const providerFamilies = [
|
||||
["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
|
||||
["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
|
||||
["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
|
||||
["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
|
||||
["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
|
||||
["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
|
||||
] as const
|
||||
|
||||
describe("OpenAI-compatible Chat route", () => {
|
||||
it.effect("prepares generic Chat target", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -91,39 +81,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
test("provides model helpers for compatible provider families", () => {
|
||||
expect(
|
||||
providerFamilies.map(([provider, family]) => {
|
||||
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
|
||||
return {
|
||||
id: String(model.id),
|
||||
provider: String(model.provider),
|
||||
route: model.route.id,
|
||||
baseURL: model.route.endpoint.baseURL,
|
||||
}
|
||||
}),
|
||||
).toEqual(
|
||||
providerFamilies.map(([provider, _, baseURL]) => ({
|
||||
id: `${provider}-model`,
|
||||
provider,
|
||||
route: "openai-compatible-chat",
|
||||
baseURL,
|
||||
})),
|
||||
)
|
||||
|
||||
const custom = OpenAICompatible.deepseek
|
||||
.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.deepseek.test/v1",
|
||||
})
|
||||
.model("deepseek-chat")
|
||||
expect(custom).toMatchObject({
|
||||
provider: "deepseek",
|
||||
route: { id: "openai-compatible-chat" },
|
||||
})
|
||||
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
|
||||
})
|
||||
|
||||
it.effect("matches AI SDK compatible basic request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
@@ -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,264 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("maps grouped and collapsed switches to the timeline settings", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const grouped = shell.getByRole("switch", { name: "Shell grouped", exact: true })
|
||||
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
|
||||
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
|
||||
|
||||
await expect(component.getByRole("switch")).toHaveCount(9)
|
||||
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
|
||||
await expect(grouped).toBeChecked()
|
||||
await expect(collapsed).toBeChecked()
|
||||
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
|
||||
await expect(value).toContainText('"shell":{"placement":"separate","details":"expanded"}')
|
||||
await grouped.focus()
|
||||
await grouped.press("Space")
|
||||
await collapsed.focus()
|
||||
await collapsed.press("Space")
|
||||
await expect(value).toContainText('"shell":{"placement":"grouped","details":"collapsed"}')
|
||||
await expect(component.getByRole("group", { name: "Subagents", exact: true }).getByRole("switch")).toHaveCount(1)
|
||||
})
|
||||
|
||||
story("replaces hidden switches with solid lines and restores options", async ({ mount, page }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const visibility = shell.getByRole("button", { name: "Shell visibility" })
|
||||
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
|
||||
const color = await label.evaluate((element) => getComputedStyle(element).color)
|
||||
const iconColor = await visibility.evaluate((element) => getComputedStyle(element).color)
|
||||
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
|
||||
await visibility.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Hide")
|
||||
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
|
||||
await visibility.evaluate((element) => element.getBoundingClientRect().top),
|
||||
)
|
||||
await visibility.click()
|
||||
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(shell.getByRole("switch")).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
|
||||
"border-top-style",
|
||||
"solid",
|
||||
)
|
||||
await expect(component.locator('[data-slot="timeline-detail-fixture-value"]')).toContainText(
|
||||
'"shell":{"placement":"hidden","details":"expanded"}',
|
||||
)
|
||||
await expect(label).not.toHaveCSS("color", color)
|
||||
await expect(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye-slash")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).hover()
|
||||
await expect(visibility).not.toHaveCSS("color", iconColor)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
|
||||
"border-top-color",
|
||||
await visibility.evaluate((element) => getComputedStyle(element).color),
|
||||
)
|
||||
await visibility.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Show")
|
||||
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
|
||||
await visibility.evaluate((element) => element.getBoundingClientRect().top),
|
||||
)
|
||||
await visibility.click()
|
||||
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(label).toHaveCSS("color", color)
|
||||
await expect(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye")
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
|
||||
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeEnabled()
|
||||
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeEnabled()
|
||||
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
|
||||
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).not.toBeChecked()
|
||||
})
|
||||
|
||||
story("toggles visibility by clicking the activity label", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
|
||||
const visibility = shell.getByRole("button", { name: "Shell visibility" })
|
||||
|
||||
await expect(label).toHaveCSS("cursor", "default")
|
||||
await label.click()
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(shell.getByRole("switch")).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
|
||||
await label.click()
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
|
||||
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeChecked()
|
||||
})
|
||||
|
||||
story("only highlights the eye when hovering the icon, not its activity label", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const visibility = shell.getByRole("button", { name: "Shell visibility" })
|
||||
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
|
||||
|
||||
for (const hidden of [false, true]) {
|
||||
if (hidden) await label.click()
|
||||
await visibility.hover()
|
||||
await expect(visibility).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
await label.hover()
|
||||
await expect(visibility).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
}
|
||||
})
|
||||
|
||||
story("opens advanced on returning to custom settings but not presets", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
const advanced = component.getByRole("button", { name: "Advanced", exact: true })
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "false")
|
||||
await advanced.click()
|
||||
await component.locator('[data-category="shell"][data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await advanced.click()
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "false")
|
||||
await component.getByRole("button", { name: "Leave settings" }).click()
|
||||
await expect(advanced).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Return to settings" }).click()
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
|
||||
|
||||
await component.getByRole("slider").press("End")
|
||||
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Everything")
|
||||
await component.getByRole("button", { name: "Leave settings" }).click()
|
||||
await component.getByRole("button", { name: "Return to settings" }).click()
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
story("keeps visibility and switches in sync with the preset slider", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const slider = component.getByRole("slider", { name: "Timeline detail" })
|
||||
await slider.focus()
|
||||
await slider.press("Home")
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
|
||||
await expect(component.getByRole("switch")).toHaveCount(0)
|
||||
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
|
||||
await component.getByRole("button", { name: "Shell visibility" }).click()
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await slider.focus()
|
||||
await slider.press("End")
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
|
||||
await expect(component.getByRole("switch")).toHaveCount(9)
|
||||
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
|
||||
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 })
|
||||
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
|
||||
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
|
||||
"color",
|
||||
await component
|
||||
.locator('[data-slot="settings-row-title"]')
|
||||
.evaluate((element) => getComputedStyle(element).color),
|
||||
)
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const track = component.locator('[data-slot="timeline-detail-track"]')
|
||||
await expect(track).toHaveCSS(
|
||||
"--timeline-detail-track-background",
|
||||
await track.evaluate(
|
||||
(element, theme) =>
|
||||
getComputedStyle(element)
|
||||
.getPropertyValue(theme === "light" ? "--v2-background-bg-layer-04" : "--v2-background-bg-layer-03")
|
||||
.trim(),
|
||||
theme,
|
||||
),
|
||||
)
|
||||
if (theme === "light") {
|
||||
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
|
||||
await expect(track).toHaveCSS(
|
||||
"--timeline-detail-marker-background",
|
||||
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
|
||||
)
|
||||
}
|
||||
if (theme === "dark") {
|
||||
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
|
||||
const list = component.locator('[data-slot="timeline-detail-list"]')
|
||||
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
|
||||
for (const [column, field] of [
|
||||
[2, "placement"],
|
||||
[3, "details"],
|
||||
] as const) {
|
||||
const heading = await component
|
||||
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.x + rect.width / 2
|
||||
})
|
||||
const toggle = await component
|
||||
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.x + rect.width / 2
|
||||
})
|
||||
expect(Math.abs(heading - toggle)).toBeLessThan(1)
|
||||
}
|
||||
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
|
||||
for (const width of [900, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
|
||||
expect(await list.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const visibility = component.getByRole("button", { name: "Shell visibility" })
|
||||
await visibility.focus()
|
||||
await visibility.press("Space")
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toHaveCount(0)
|
||||
await expect(
|
||||
component
|
||||
.getByRole("group", { name: "Shell", exact: true })
|
||||
.locator('[data-slot="timeline-detail-unavailable"]'),
|
||||
).toHaveCount(2)
|
||||
await visibility.press("Space")
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "true")
|
||||
|
||||
const slider = component.getByRole("slider", { name: "Timeline detail" })
|
||||
const track = component.locator('[data-slot="timeline-detail-track"]')
|
||||
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
|
||||
await component
|
||||
.locator('[data-slot="timeline-detail-scale"]')
|
||||
.evaluate((element) => element.getBoundingClientRect().width),
|
||||
)
|
||||
await slider.focus()
|
||||
await slider.press("Home")
|
||||
for (const position of [0, 1, 2, 3, 4]) {
|
||||
if (position > 0) await slider.press("ArrowUp")
|
||||
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
|
||||
const fill = await track.evaluate((element) => {
|
||||
const style = getComputedStyle(element, "::before")
|
||||
return {
|
||||
fraction: parseFloat(style.width) / element.getBoundingClientRect().width,
|
||||
start: style.getPropertyValue("inset-inline-start"),
|
||||
color: style.backgroundColor,
|
||||
remainder: getComputedStyle(element).backgroundColor,
|
||||
}
|
||||
})
|
||||
expect(fill.fraction).toBeCloseTo(position / 4, 2)
|
||||
expect(fill.start).toBe("0px")
|
||||
expect(fill.color).not.toBe(fill.remainder)
|
||||
const marker = await track.locator("span").nth(position).boundingBox()
|
||||
const bounds = await track.boundingBox()
|
||||
expect(marker).not.toBeNull()
|
||||
expect(bounds).not.toBeNull()
|
||||
expect((marker!.x + marker!.width / 2 - bounds!.x) / bounds!.width).toBeCloseTo(
|
||||
direction === "rtl" ? 1 - position / 4 : position / 4,
|
||||
2,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -23,17 +23,21 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(slider).toBeEnabled()
|
||||
await slider.press("Home")
|
||||
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
|
||||
for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) {
|
||||
if (index) await slider.press("ArrowRight")
|
||||
await expect(slider).toHaveValue(String(index))
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", name)
|
||||
}
|
||||
await slider.press("Home")
|
||||
await slider.press("End")
|
||||
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
|
||||
await page.getByRole("option", { name: "Grouped", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
|
||||
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
|
||||
const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true })
|
||||
const collapsed = settings.getByRole("switch", { name: "Thinking collapsed", exact: true })
|
||||
await expect(grouped).not.toBeChecked()
|
||||
await expect(collapsed).not.toBeChecked()
|
||||
await settings.locator('[data-category="thinking"][data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await settings.locator('[data-category="thinking"][data-field="details"] [data-slot="switch-control"]').click()
|
||||
await expect(grouped).toBeChecked()
|
||||
await expect(collapsed).toBeChecked()
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -33,7 +33,7 @@ for (const viewport of [
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(settings).toBeFocused()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wheel over the outer gutter must not move the entire settings screen.
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
|
||||
"./updater": "./src/shell/updates/types.ts",
|
||||
"./wsl/types": "./src/servers/wsl/types.ts",
|
||||
"./ssh": "./src/servers/ssh/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
|
||||
import { SettingsProvider } from "@/settings/model"
|
||||
import { TabsProvider } from "@/shell/tabs/tabs"
|
||||
import { WslServersProvider } from "@/servers/wsl/context"
|
||||
import { SshServersProvider } from "@/servers/ssh/context"
|
||||
import { SshRestore } from "@/servers/ssh/restore"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
|
||||
|
||||
@@ -80,9 +82,11 @@ export function AppBaseProviders(
|
||||
>
|
||||
<QueryProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DialogProvider>
|
||||
<SshServersProvider>
|
||||
<DialogProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DialogProvider>
|
||||
</SshServersProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -109,6 +113,7 @@ export function AppInterface(props: {
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ComposerEditorView = {
|
||||
agent?: ComposerSelectControl
|
||||
variant?: ComposerSelectControl
|
||||
submit: {
|
||||
available?: Accessor<boolean>
|
||||
stopping: Accessor<boolean>
|
||||
working?: Accessor<boolean>
|
||||
queue?: ComposerQueue
|
||||
@@ -333,6 +334,7 @@ export function createComposerEditor(input: {
|
||||
draft.removeAttachment(id)
|
||||
},
|
||||
canSubmit() {
|
||||
if (input.view.submit.available?.() === false) return false
|
||||
if (input.view.draftOnly) return false
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
@@ -365,6 +367,7 @@ export function createComposerEditor(input: {
|
||||
dispatch({ type: "mode.shell" })
|
||||
},
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.submit.available?.() === false) return
|
||||
if (input.view.draftOnly) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
@@ -30,6 +30,8 @@ export type ComposerModel = ComposerEditorModel & {
|
||||
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const available = () => server.conn.type !== "ssh" || server.ctx.sdk.connection.status() === "connected"
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
@@ -394,10 +396,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||
},
|
||||
submit: {
|
||||
available,
|
||||
stopping,
|
||||
working: adapter.working,
|
||||
queue: options?.queue,
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
|
||||
@@ -7,5 +7,8 @@ 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 { useSshServers } from "./servers/ssh/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadInitialLocale } from "@/runtime/i18n/language"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createWebPlatform } from "@/runtime/platform/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
|
||||
import { KeyboardInsets } from "@/runtime/platform/keyboard"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import zh from "@/runtime/i18n/zh"
|
||||
import { authFromToken } from "@/runtime/server/api"
|
||||
@@ -92,6 +93,7 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
>
|
||||
<KeyboardInsets />
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
</AppInterface>
|
||||
</AppBaseProviders>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -365,7 +365,10 @@ function HomeServerRow(props: {
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
<ServerHealthIndicator
|
||||
health={props.health}
|
||||
connecting={props.server.type === "ssh" && props.server.connecting}
|
||||
/>
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,47 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"ssh.label": "SSH",
|
||||
"ssh.offline": "Not connected to {{host}}. Your draft is preserved; remote work may still be running.",
|
||||
"ssh.placeholder": "ssh user@example.com",
|
||||
"ssh.add": "Add SSH server",
|
||||
"ssh.server.menu.label": "SSH server",
|
||||
"ssh.target": "Host or SSH command",
|
||||
"ssh.connect": "Connect",
|
||||
"ssh.connectTo": "Connect to {{host}}",
|
||||
"ssh.authenticate": "SSH authentication",
|
||||
"ssh.trust": "Trust and connect",
|
||||
"ssh.continue": "Continue",
|
||||
"ssh.retry": "Retry",
|
||||
"ssh.update": "Update and reconnect",
|
||||
"ssh.openProject": "Open project",
|
||||
"ssh.project": "Open project on {{host}}",
|
||||
"ssh.disconnect": "Disconnect",
|
||||
"ssh.forget": "Forget connection",
|
||||
"ssh.stage.disconnected": "Disconnected. The remote server is left running.",
|
||||
"ssh.stage.connecting": "Connecting over SSH…",
|
||||
"ssh.stage.checking": "Checking OpenCode…",
|
||||
"ssh.stage.downloading": "Downloading server…",
|
||||
"ssh.stage.uploading": "Uploading server…",
|
||||
"ssh.stage.starting": "Connecting to OpenCode…",
|
||||
"ssh.stage.ready": "Connected",
|
||||
"ssh.stage.authentication": "Authentication required",
|
||||
"ssh.stage.incompatible": "Server update required",
|
||||
"ssh.stage.failed": "Connection failed",
|
||||
"ssh.error.input":
|
||||
"Enter a host or SSH connection command. Remote commands and unsupported SSH options aren’t allowed.",
|
||||
"ssh.error.connection": "Could not establish the SSH connection. Check your network and SSH configuration.",
|
||||
"ssh.error.platform":
|
||||
"This remote platform is not supported. Automatic setup currently requires Linux or macOS on x64 or arm64.",
|
||||
"ssh.error.version": "The remote service must match this Desktop version before connecting.",
|
||||
"ssh.error.install":
|
||||
"Could not install the remote server. Check connectivity, disk space, and that tar is installed.",
|
||||
"ssh.error.unpublished":
|
||||
"This Desktop version has no published remote server. For development builds, install and start V2 on the host, then retry.",
|
||||
"ssh.error.service": "SSH connected, but the OpenCode server did not become ready.",
|
||||
"ssh.error.host-key":
|
||||
"The host’s identity could not be verified. Verify its fingerprint before updating your SSH known hosts.",
|
||||
"ssh.error.ssh-missing": "OpenSSH was not found. Install an OpenSSH client and ensure ssh is available on PATH.",
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
@@ -659,6 +700,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 +718,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 +778,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 +811,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}}",
|
||||
@@ -981,37 +1035,36 @@ export const dict = {
|
||||
|
||||
"settings.timeline.title": "Timeline",
|
||||
"settings.timeline.detail": "Timeline detail",
|
||||
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
|
||||
"settings.timeline.description": "Choose how much detail appears in the session timeline.",
|
||||
"settings.timeline.summary": "{{preset}}:",
|
||||
"settings.timeline.preset.everything": "Everything",
|
||||
"settings.timeline.preset.detailed": "Detailed",
|
||||
"settings.timeline.preset.compact": "Compact",
|
||||
"settings.timeline.preset.quiet": "Quiet",
|
||||
"settings.timeline.preset.text-only": "Text only",
|
||||
"settings.timeline.preset.text-only": "Messages only",
|
||||
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
|
||||
"settings.timeline.description.detailed":
|
||||
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
|
||||
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
|
||||
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
|
||||
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
|
||||
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
|
||||
"Expand shell output and edits. Show subagents separately and group other activity.",
|
||||
"settings.timeline.description.compact": "Group all activity with details collapsed.",
|
||||
"settings.timeline.description.quiet": "Group edits and subagents. Hide other activity.",
|
||||
"settings.timeline.description.text-only": "Hide all activity.",
|
||||
"settings.timeline.description.custom": "Uses advanced settings.",
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
|
||||
"settings.timeline.activity": "Activity",
|
||||
"settings.timeline.group": "Group",
|
||||
"settings.timeline.collapse": "Collapse",
|
||||
"settings.timeline.visibility.show": "Show",
|
||||
"settings.timeline.visibility.hide": "Hide",
|
||||
"settings.timeline.visibility.label": "{{activity}} visibility",
|
||||
"settings.timeline.grouped.label": "{{activity}} grouped",
|
||||
"settings.timeline.collapsed.label": "{{activity}} collapsed",
|
||||
"settings.timeline.category.shell": "Shell",
|
||||
"settings.timeline.category.edit": "Edits",
|
||||
"settings.timeline.category.thinking": "Thinking",
|
||||
"settings.timeline.category.subagents": "Subagents",
|
||||
"settings.timeline.category.notices": "Notices",
|
||||
"settings.timeline.category.tools": "Other tools",
|
||||
"settings.timeline.placement.title": "Placement",
|
||||
"settings.timeline.placement.separate": "Separate",
|
||||
"settings.timeline.placement.grouped": "Grouped",
|
||||
"settings.timeline.placement.hidden": "Hidden",
|
||||
"settings.timeline.expansion.title": "Details",
|
||||
"settings.timeline.expansion.collapsed": "Collapsed",
|
||||
"settings.timeline.expansion.expanded": "Expanded",
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
@@ -1045,8 +1098,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 +1291,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 +1338,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 +1360,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
|
||||
@@ -78,3 +108,28 @@ Storage-key relocation (`previousKey`, workspace aliases, draft storage moves)
|
||||
remains separate from schema migration. Draft blob externalization and hydration
|
||||
also remain in the storage adapter: composer codecs receive hydrated references,
|
||||
not raw ID-only blob documents.
|
||||
|
||||
## Large draft content
|
||||
|
||||
Draft documents never carry large text inline. Any string of `draftTextThreshold`
|
||||
characters or more is split into `draftTextChunk`-sized content-addressed blobs and
|
||||
stored as `{ blob: { kind: "text", ids: [...] } }`; reads join the chunks again. A
|
||||
content-keyed cache means unchanged chunks are not hashed or sent on later saves, so
|
||||
typing after a large paste uploads one chunk per save rather than the paste. Chunk
|
||||
boundaries never split a surrogate pair, and a failed upload is evicted so the next save
|
||||
retries it.
|
||||
|
||||
Blob collection is made safe by validation on write, not by timing. A strict document write
|
||||
is refused while it references blob ids the store does not hold, so the previous document
|
||||
stays visible; the renderer uploads the missing bytes again from the chunk text or the image
|
||||
`Blob` it still holds, renames the references to the ids the uploads returned (content hashes
|
||||
normally, fresh ids on a store without WebCrypto), and publishes. This covers a chunk another
|
||||
tab collected, and an image the composer kept in its history long after its blob was
|
||||
collected. The desktop host additionally refreshes `touched_at` for every blob a written
|
||||
document references and collects only blobs unreferenced and untouched for `blobGrace`, so
|
||||
repairs stay rare.
|
||||
`persisted()` hands the draft store the encoded document (`setDocument`) rather than
|
||||
a serialized string, so the store does not re-parse the full document to externalize
|
||||
it. Both blob collectors (desktop SQL, browser IndexedDB) keep chunk ids alive. This
|
||||
follows VS Code's rule that editor content lives in per-resource backups, not in the
|
||||
state database.
|
||||
|
||||
@@ -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,232 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
|
||||
|
||||
function memoryDriver() {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
let puts = 0
|
||||
return {
|
||||
documents,
|
||||
blobs,
|
||||
puts: () => puts,
|
||||
driver: {
|
||||
get: async (key: string) => documents.get(key) ?? null,
|
||||
// Like the real stores: report referenced blobs that are not held; a strict write with any
|
||||
// missing is refused.
|
||||
set: async (key: string, value: string, strict: boolean) => {
|
||||
const ids = new Set<string>()
|
||||
JSON.parse(value, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") ids.add(item.blob.id)
|
||||
if (item?.blob && Array.isArray(item.blob.ids)) item.blob.ids.forEach((id: unknown) => ids.add(String(id)))
|
||||
return item
|
||||
})
|
||||
const missing = [...ids].filter((id) => !blobs.has(id))
|
||||
if (!strict || missing.length === 0) documents.set(key, value)
|
||||
return missing
|
||||
},
|
||||
remove: async (key: string) => void documents.delete(key),
|
||||
putBlob: async (blob: Blob) => {
|
||||
puts++
|
||||
const id = `blob-${await blob.text().then((text) => Bun.hash(text).toString(16))}`
|
||||
blobs.set(id, blob)
|
||||
return id
|
||||
},
|
||||
getBlob: async (id: string) => blobs.get(id) ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const large = "x".repeat(draftTextThreshold)
|
||||
const paste = Array.from({ length: 3 * draftTextChunk }, (_, i) => String.fromCharCode(97 + (i % 26))).join("")
|
||||
|
||||
describe("draft store text externalization", () => {
|
||||
test("large strings become chunk lists and small ones stay inline", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "text", content: "hi" },
|
||||
],
|
||||
})
|
||||
const stored = JSON.parse(memory.documents.get("doc")!)
|
||||
expect(stored.prompt[0].content.blob.kind).toBe("text")
|
||||
expect(stored.prompt[0].content.blob.ids).toHaveLength(3)
|
||||
expect(stored.prompt[1].content).toBe("hi")
|
||||
expect(memory.documents.get("doc")!.length).toBeLessThan(400)
|
||||
const chunks = await Promise.all(
|
||||
stored.prompt[0].content.blob.ids.map((id: string) => memory.blobs.get(id)!.text()),
|
||||
)
|
||||
expect(chunks.join("")).toBe(paste)
|
||||
})
|
||||
|
||||
test("appending to a large string re-uploads only the final chunk", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })
|
||||
expect(memory.puts()).toBe(3)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }] })
|
||||
expect(memory.puts()).toBe(4)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }], cursor: 1 })
|
||||
expect(memory.puts()).toBe(4)
|
||||
})
|
||||
|
||||
test("reads join the chunks again and reuse cached content", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: paste }] })
|
||||
memory.blobs.clear()
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: paste }] })
|
||||
})
|
||||
|
||||
test("a missing chunk decodes to empty text instead of failing the document", async () => {
|
||||
const memory = memoryDriver()
|
||||
memory.documents.set(
|
||||
"doc",
|
||||
JSON.stringify({ prompt: [{ type: "text", content: { blob: { kind: "text", ids: ["gone"] } } }] }),
|
||||
)
|
||||
const store = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await store.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: "" }] })
|
||||
})
|
||||
|
||||
test("a cached chunk id the store no longer holds is uploaded again on the next save", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
const [id] = JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${large}!` }] })
|
||||
// Another tab collected the chunk for `large` while this tab still caches its id.
|
||||
memory.blobs.clear()
|
||||
// Undo republishes the cached id; the write reports it missing and the chunk is uploaded again.
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
expect(memory.puts()).toBe(3)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(large)
|
||||
expect(memory.blobs.has(id)).toBe(true)
|
||||
})
|
||||
|
||||
test("an image reference whose blob was collected is restored from its object url", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
const image = await store.putBlob(new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }))
|
||||
// The composer kept this reference (for example in its history) while the store collected the bytes.
|
||||
memory.blobs.clear()
|
||||
await store.setDocument("doc", { prompt: [{ type: "image", blob: { id: image.id, url: image.url } }] })
|
||||
expect(memory.blobs.has(image.id)).toBe(true)
|
||||
expect(new Uint8Array(await memory.blobs.get(image.id)!.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]))
|
||||
})
|
||||
|
||||
test("the previous document stays visible until missing blobs are restored", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${large}!` }] })
|
||||
const before = memory.documents.get("doc")
|
||||
memory.blobs.clear()
|
||||
// Hold the repair upload: while it is pending, another reader must still see the old document.
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const putBlob = memory.driver.putBlob
|
||||
memory.driver.putBlob = async (blob) => {
|
||||
await gate.promise
|
||||
return putBlob(blob)
|
||||
}
|
||||
const saving = store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(memory.documents.get("doc")).toBe(before)
|
||||
gate.resolve()
|
||||
await saving
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(large)
|
||||
})
|
||||
|
||||
test("references are renamed when a restored blob comes back under a different id", async () => {
|
||||
const memory = memoryDriver()
|
||||
// A store without WebCrypto assigns a fresh id to every upload.
|
||||
let counter = 0
|
||||
memory.driver.putBlob = async (blob) => {
|
||||
const id = `random-${counter++}`
|
||||
memory.blobs.set(id, blob)
|
||||
return id
|
||||
}
|
||||
const store = createDraftStore(memory.driver)
|
||||
const image = await store.putBlob(new Blob([new Uint8Array([7])], { type: "image/png" }))
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "image", blob: image },
|
||||
],
|
||||
})
|
||||
const original = JSON.parse(memory.documents.get("doc")!)
|
||||
memory.blobs.clear()
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "image", blob: image },
|
||||
],
|
||||
})
|
||||
const restored = JSON.parse(memory.documents.get("doc")!)
|
||||
expect(restored.prompt[0].content.blob.ids).not.toEqual(original.prompt[0].content.blob.ids)
|
||||
expect(restored.prompt[1].blob.id).not.toBe(original.prompt[1].blob.id)
|
||||
for (const id of [...restored.prompt[0].content.blob.ids, restored.prompt[1].blob.id])
|
||||
expect(memory.blobs.has(id)).toBe(true)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
const read = JSON.parse((await fresh.getItem("doc"))!)
|
||||
expect(read.prompt[0].content).toBe(paste)
|
||||
expect(read.prompt[1].blob.id).toBe(restored.prompt[1].blob.id)
|
||||
// The renamed ids are what later encodes publish, so saves of the still-live references (the
|
||||
// composer keeps the original image id) upload nothing and keep one stable image id.
|
||||
const puts = counter
|
||||
for (const cursor of [1, 2, 3]) {
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "image", blob: image },
|
||||
],
|
||||
cursor,
|
||||
})
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[1].blob.id).toBe(restored.prompt[1].blob.id)
|
||||
}
|
||||
expect(counter).toBe(puts)
|
||||
})
|
||||
|
||||
test("chunk boundaries never split a surrogate pair", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
const text = "x".repeat(draftTextChunk - 1) + "😀tail"
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: text }] })
|
||||
const stored = JSON.parse(memory.documents.get("doc")!)
|
||||
const bytes = await Promise.all(stored.prompt[0].content.blob.ids.map((id: string) => memory.blobs.get(id)!.text()))
|
||||
expect(bytes.join("")).toBe(text)
|
||||
expect(bytes[0]!.length).toBe(draftTextChunk + 1)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(text)
|
||||
})
|
||||
|
||||
test("a failed chunk upload is retried on the next save instead of being reused", async () => {
|
||||
const memory = memoryDriver()
|
||||
let failNext = true
|
||||
const putBlob = memory.driver.putBlob
|
||||
memory.driver.putBlob = async (blob) => {
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw new Error("offline")
|
||||
}
|
||||
return putBlob(blob)
|
||||
}
|
||||
const store = createDraftStore(memory.driver)
|
||||
await expect(store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })).rejects.toThrow("offline")
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }] })
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(`${paste}!`)
|
||||
})
|
||||
|
||||
test("setItem still accepts a serialized document", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setItem("doc", JSON.stringify({ prompt: [{ type: "text", content: large }] }))
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -5,20 +5,43 @@ export type BlobReference = { id: string; url: string }
|
||||
|
||||
type Driver = {
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string): Promise<void>
|
||||
/**
|
||||
* Store the document and report every blob id it references that the store does not hold. A
|
||||
* strict write is refused (nothing stored) when any are missing, so a document is never visible
|
||||
* with dangling references while its blobs are being restored.
|
||||
*/
|
||||
set(key: string, value: string, strict: boolean): Promise<readonly string[]>
|
||||
remove(key: string): Promise<void>
|
||||
putBlob(blob: Blob): Promise<string>
|
||||
getBlob(id: string): Promise<Blob | null>
|
||||
}
|
||||
|
||||
export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
|
||||
export type DraftStore = AsyncStorage & {
|
||||
putBlob(blob: Blob): Promise<BlobReference>
|
||||
/** Persist an already-encoded document without re-parsing its serialized form. */
|
||||
setDocument(key: string, document: unknown): Promise<void>
|
||||
}
|
||||
|
||||
// Strings at least this long leave the document as fixed-size content-addressed chunks. Typing
|
||||
// after a large paste changes only the final chunk, so a save uploads one chunk, not the paste.
|
||||
export const draftTextThreshold = 16 * 1024
|
||||
export const draftTextChunk = 64 * 1024
|
||||
const textCacheLimit = 64
|
||||
|
||||
const urls = new Map<string, string>()
|
||||
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
|
||||
// collected image be uploaded again without fetching the URL.
|
||||
const held = new Map<string, Blob>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
function blobUrl(id: string, blob: Blob) {
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.set(id, url)
|
||||
held.set(id, blob)
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -56,25 +79,82 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
const encode = async (value: unknown): Promise<unknown> => {
|
||||
if (Array.isArray(value)) return Promise.all(value.map(encode))
|
||||
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
|
||||
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
|
||||
// the store has since collected the blob: the write reports it missing and it is uploaded again.
|
||||
const chunkIds = new Map<string, Promise<string>>()
|
||||
const chunks = new Map<string, string>()
|
||||
const remember = <V>(cache: Map<string, V>, key: string, value: V) => {
|
||||
cache.set(key, value)
|
||||
if (cache.size > textCacheLimit) cache.delete(cache.keys().next().value!)
|
||||
return value
|
||||
}
|
||||
const upload = (chunk: string) => {
|
||||
const id = driver.putBlob(new Blob([chunk])).then(
|
||||
(id) => {
|
||||
remember(chunks, id, chunk)
|
||||
return id
|
||||
},
|
||||
(error: unknown) => {
|
||||
// A failed upload must not be reused as the answer for this content on later saves.
|
||||
if (chunkIds.get(chunk) === id) chunkIds.delete(chunk)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
return remember(chunkIds, chunk, id)
|
||||
}
|
||||
const externalize = (text: string) => Promise.all(split(text).map((chunk) => chunkIds.get(chunk) ?? upload(chunk)))
|
||||
const loadChunk = async (id: string) => {
|
||||
const cached = chunks.get(id)
|
||||
if (cached !== undefined) return cached
|
||||
const blob = await driver.getBlob(id)
|
||||
// A missing chunk loses that text but keeps the rest of the document decodable.
|
||||
return remember(chunks, id, blob ? await blob.text() : "")
|
||||
}
|
||||
// `sources` collects, for every blob id the encoded document references, a way to produce its
|
||||
// bytes again: the chunk text itself, or the image Blob (or object URL) the reference carries.
|
||||
type Sources = Map<string, { blob: () => Promise<Blob>; chunk?: string }>
|
||||
const encode = async (value: unknown, sources: Sources): Promise<unknown> => {
|
||||
if (typeof value === "string" && value.length >= draftTextThreshold) {
|
||||
const pieces = split(value)
|
||||
const ids = await externalize(value)
|
||||
ids.forEach((id, index) =>
|
||||
sources.set(id, { blob: async () => new Blob([pieces[index]!]), chunk: pieces[index] }),
|
||||
)
|
||||
return { blob: { kind: "text", ids } }
|
||||
}
|
||||
if (Array.isArray(value)) return Promise.all(value.map((entry) => encode(entry, sources)))
|
||||
if (!value || typeof value !== "object") return value
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.type === "image" && typeof item.dataUrl === "string") {
|
||||
const blob = await fetch(item.dataUrl).then((response) => response.blob())
|
||||
const { dataUrl: _, ...rest } = item
|
||||
return { ...rest, blob: { id: await driver.putBlob(blob) } }
|
||||
const id = await driver.putBlob(blob)
|
||||
sources.set(id, { blob: async () => blob })
|
||||
return { ...rest, blob: { id } }
|
||||
}
|
||||
if ("blob" in item && item.blob && typeof item.blob === "object") {
|
||||
const blob = item.blob as Record<string, unknown>
|
||||
if (blob.kind === "text") return item
|
||||
if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
|
||||
const data = await fetch(blob.id).then((response) => response.blob())
|
||||
return { ...item, blob: { id: await driver.putBlob(data) } }
|
||||
const id = await driver.putBlob(data)
|
||||
sources.set(id, { blob: async () => data })
|
||||
return { ...item, blob: { id } }
|
||||
}
|
||||
if (typeof blob.id === "string") {
|
||||
// A live reference keeps the id it was created with; publish the id its bytes now live under.
|
||||
const id = aliases.get(blob.id) ?? blob.id
|
||||
const kept = held.get(id)
|
||||
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
|
||||
if (kept) sources.set(id, { blob: async () => kept })
|
||||
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
|
||||
return { ...item, blob: { id } }
|
||||
}
|
||||
return { ...item, blob: { id: blob.id } }
|
||||
}
|
||||
return Object.fromEntries(
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry, sources)])),
|
||||
)
|
||||
}
|
||||
const decode = async (value: unknown): Promise<unknown> => {
|
||||
@@ -83,6 +163,9 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.blob && typeof item.blob === "object") {
|
||||
const ref = item.blob as Record<string, unknown>
|
||||
if (ref.kind === "text" && Array.isArray(ref.ids)) {
|
||||
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
|
||||
}
|
||||
if (typeof ref.id === "string") {
|
||||
const url = await loadBlobUrl(ref.id)
|
||||
if (url) return { ...item, blob: { id: ref.id, url } }
|
||||
@@ -92,6 +175,65 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
|
||||
)
|
||||
}
|
||||
// Upload the bytes behind `ids` again and return the ids they were stored under. Ids are
|
||||
// usually content hashes and come back unchanged, but a store without WebCrypto assigns fresh
|
||||
// ones, so callers must rename references rather than assume.
|
||||
const restore = async (ids: readonly string[], sources: Sources) => {
|
||||
const renamed = new Map<string, string>()
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const source = sources.get(id)
|
||||
if (!source) return
|
||||
const blob = await source.blob()
|
||||
const next = await driver.putBlob(blob)
|
||||
renamed.set(id, next)
|
||||
if (source.chunk !== undefined) {
|
||||
remember(chunkIds, source.chunk, Promise.resolve(next))
|
||||
remember(chunks, next, source.chunk)
|
||||
return
|
||||
}
|
||||
held.set(next, blob)
|
||||
blobUrl(next, blob)
|
||||
if (next === id) return
|
||||
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
|
||||
// earlier alias chain so lookups stay one step.
|
||||
for (const [from, to] of aliases) if (to === id) aliases.set(from, next)
|
||||
aliases.set(id, next)
|
||||
}),
|
||||
)
|
||||
return renamed
|
||||
}
|
||||
const rename = (value: unknown, renamed: Map<string, string>): unknown => {
|
||||
if (Array.isArray(value)) return value.map((entry) => rename(entry, renamed))
|
||||
if (!value || typeof value !== "object") return value
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.blob && typeof item.blob === "object") {
|
||||
const ref = item.blob as Record<string, unknown>
|
||||
if (Array.isArray(ref.ids))
|
||||
return { ...item, blob: { ...ref, ids: ref.ids.map((id) => renamed.get(String(id)) ?? id) } }
|
||||
if (typeof ref.id === "string") return { ...item, blob: { ...ref, id: renamed.get(ref.id) ?? ref.id } }
|
||||
}
|
||||
return Object.fromEntries(Object.entries(item).map(([key, entry]) => [key, rename(entry, renamed)]))
|
||||
}
|
||||
const setDocument = async (key: string, document: unknown) => {
|
||||
const version = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, version)
|
||||
const sources: Sources = new Map()
|
||||
const encoded = await encode(document, sources)
|
||||
if (versions.get(key) !== version) return
|
||||
// The store refuses the write while any referenced blob is missing, so the previous document
|
||||
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
|
||||
// or the composer's history still held its id.
|
||||
const missing = await driver.set(key, JSON.stringify(encoded), true)
|
||||
if (missing.length === 0) return
|
||||
const renamed = await restore(missing, sources)
|
||||
if (versions.get(key) !== version) return
|
||||
const unrestored = missing.filter((id) => !renamed.has(id))
|
||||
if (unrestored.length)
|
||||
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
|
||||
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
|
||||
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
|
||||
}
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
const value = await driver.get(key)
|
||||
@@ -101,12 +243,8 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
if (Option.isNone(parsed)) return value
|
||||
return JSON.stringify(await decode(parsed.value))
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
const version = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, version)
|
||||
const encoded = JSON.stringify(await encode(JSON.parse(value)))
|
||||
if (versions.get(key) === version) await driver.set(key, encoded)
|
||||
},
|
||||
setItem: (key, value) => setDocument(key, JSON.parse(value)),
|
||||
setDocument,
|
||||
removeItem: async (key) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
await driver.remove(key)
|
||||
@@ -115,6 +253,20 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-size pieces, except that a piece never ends between the two halves of a surrogate pair:
|
||||
// each piece becomes its own Blob, and an unpaired surrogate would be encoded as U+FFFD.
|
||||
function split(text: string) {
|
||||
const pieces: string[] = []
|
||||
for (let start = 0; start < text.length; ) {
|
||||
const end = Math.min(start + draftTextChunk, text.length)
|
||||
const code = text.charCodeAt(end - 1)
|
||||
const stop = end < text.length && code >= 0xd800 && code <= 0xdbff ? end + 1 : end
|
||||
pieces.push(text.slice(start, stop))
|
||||
start = stop
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
export function createBrowserDraftStore(): DraftStore {
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
@@ -127,11 +279,7 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
const transaction = database.transaction(["documents", "blobs"], "readwrite")
|
||||
const documents = transaction.objectStore("documents").getAll()
|
||||
documents.addEventListener("success", () => {
|
||||
const used = new Set<string>()
|
||||
JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
||||
return item
|
||||
})
|
||||
const used = referenced(`[${documents.result.join(",")}]`)
|
||||
const store = transaction.objectStore("blobs")
|
||||
const blobs = store.openKeyCursor()
|
||||
blobs.addEventListener("success", () => {
|
||||
@@ -164,7 +312,32 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
}
|
||||
return createDraftStore({
|
||||
get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
|
||||
set: (key, value) => write("documents", key, value),
|
||||
set: async (key, value, strict) => {
|
||||
// One readwrite transaction over both stores: IndexedDB serialises overlapping readwrite
|
||||
// transactions in creation order, so a later save or removal cannot commit between the
|
||||
// reference check and this write. The put is issued from the last lookup's callback so the
|
||||
// transaction is never left without a pending request.
|
||||
const ids = [...referenced(value)]
|
||||
const transaction = (await db).transaction(["blobs", "documents"], "readwrite")
|
||||
const missing: string[] = []
|
||||
const publish = () => {
|
||||
if (!strict || missing.length === 0) transaction.objectStore("documents").put(value, key)
|
||||
}
|
||||
let remaining = ids.length
|
||||
if (remaining === 0) publish()
|
||||
for (const id of ids) {
|
||||
const lookup = transaction.objectStore("blobs").getKey(id)
|
||||
lookup.addEventListener("success", () => {
|
||||
if (lookup.result === undefined) missing.push(id)
|
||||
if (--remaining === 0) publish()
|
||||
})
|
||||
}
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
transaction.addEventListener("complete", () => resolve(missing))
|
||||
transaction.addEventListener("error", () => reject(transaction.error))
|
||||
transaction.addEventListener("abort", () => reject(transaction.error))
|
||||
})
|
||||
},
|
||||
remove: (key) => write("documents", key),
|
||||
putBlob: async (blob) => {
|
||||
const id = await blobID(blob)
|
||||
@@ -175,6 +348,17 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
})
|
||||
}
|
||||
|
||||
// Every blob id a serialized document (or array of documents) references.
|
||||
function referenced(json: string) {
|
||||
const ids = new Set<string>()
|
||||
JSON.parse(json, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") ids.add(item.blob.id)
|
||||
if (item?.blob && Array.isArray(item.blob.ids)) item.blob.ids.forEach((id: unknown) => ids.add(String(id)))
|
||||
return item
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const data = await fetch(blob.url).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
|
||||
@@ -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,103 @@
|
||||
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
|
||||
/** Replaces `storage.setItem` for stores whose storage can take the value itself. */
|
||||
write?: (value: T, serialized: string) => void
|
||||
}) {
|
||||
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)
|
||||
if (input.write) return input.write(input.store, 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>,
|
||||
@@ -483,6 +484,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
const initialized = Persistence.withInitial(schema, initial)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
const encode = Schema.encodeSync(initialized)
|
||||
const serialize = Schema.encodeSync(json)
|
||||
const normalize = (raw: string) => {
|
||||
const value = decode(raw)
|
||||
@@ -491,10 +493,12 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
// The newest serialized draft, replayed into storage if a slow load finishes after an edit.
|
||||
let draftLatest: string | undefined
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (draft) {
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
return {
|
||||
getItem: (key: string) => draft.getItem(prefix + key),
|
||||
setItem: (key: string, value: string) => draft.setItem(prefix + key, value),
|
||||
@@ -556,7 +560,6 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
]
|
||||
.filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage)
|
||||
.map((source) => ({ ...source, storage: toAsyncStorage(source.storage) }))
|
||||
let draftLatest: string | undefined
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
@@ -593,13 +596,29 @@ 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,
|
||||
// Drafts take the encoded document itself so large text is externalized without the store
|
||||
// re-parsing the serialized form on every save.
|
||||
write: draft
|
||||
? (value, serialized) => {
|
||||
draftLatest = serialized
|
||||
// A failed chunk upload is retried by the next save; see drafts.ts.
|
||||
void draft
|
||||
.setDocument(prefix + config.key, encode(value))
|
||||
.catch((error: unknown) => console.error(`[persistence] draft write failed for ${config.key}`, error))
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
const state = store[0]
|
||||
const setState = persist.setStore
|
||||
const init = persist.init
|
||||
|
||||
const isAsync = init instanceof Promise
|
||||
const [ready] = createResource(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
|
||||
export function KeyboardInsets() {
|
||||
onMount(() => {
|
||||
const viewport = window.visualViewport
|
||||
if (!viewport) return
|
||||
const root = document.documentElement
|
||||
const sync = () => {
|
||||
const active = document.activeElement
|
||||
const editing =
|
||||
active instanceof HTMLElement &&
|
||||
(active.isContentEditable || active.matches("input:not([readonly]), textarea:not([readonly])"))
|
||||
// iOS retains the home-indicator inset above its keyboard. Ignore pinch zoom
|
||||
// and small viewport changes from browser chrome, not just editor focus.
|
||||
const keyboard = editing && root.clientHeight - viewport.height * viewport.scale > 100
|
||||
if (keyboard) root.style.setProperty("--safe-area-inset-bottom", "0px")
|
||||
if (!keyboard) root.style.removeProperty("--safe-area-inset-bottom")
|
||||
}
|
||||
sync()
|
||||
viewport.addEventListener("resize", sync)
|
||||
window.addEventListener("resize", sync)
|
||||
document.addEventListener("focusin", sync)
|
||||
document.addEventListener("focusout", sync)
|
||||
onCleanup(() => {
|
||||
viewport.removeEventListener("resize", sync)
|
||||
window.removeEventListener("resize", sync)
|
||||
document.removeEventListener("focusin", sync)
|
||||
document.removeEventListener("focusout", sync)
|
||||
root.style.removeProperty("--safe-area-inset-bottom")
|
||||
})
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "@/shell/commands/desktop-menu"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { SshPlatform } from "@/servers/ssh/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
|
||||
@@ -85,6 +86,7 @@ type PlatformBase = {
|
||||
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
sshServers?: SshPlatform
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { type Accessor, createEffect, on, onCleanup } from "solid-js"
|
||||
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"
|
||||
|
||||
@@ -73,8 +74,18 @@ type ServerSDKBase = {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
if (server.type === "ssh") {
|
||||
createEffect(
|
||||
on(
|
||||
() => `${server.http.url}\0${server.http.password ?? ""}`,
|
||||
() => transport.update(server.http),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
const reconnect =
|
||||
server.type === "ssh" || (server.type === "sidecar" && server.variant === "base") ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
@@ -114,8 +125,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)
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user